From f28c9221e63266a512a3bfef9a66c2dab6ccd96e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Oct 2023 20:37:16 +0200 Subject: [PATCH 001/982] Bump version to 2023.12.0dev0 (#102798) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a5c3efd1cb..29f0c9ee5d8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ env: PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 5 BLACK_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2023.11" + HA_SHORT_VERSION: "2023.12" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 77c5582464e..c6655ba3900 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,7 +6,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 11 +MINOR_VERSION: Final = 12 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 82bb7d08e26..235e41a7cca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0.dev0" +version = "2023.12.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 13378b4ae2b79a1c368509fc6150799c54a9b727 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Oct 2023 16:07:22 -0400 Subject: [PATCH 002/982] Add script to convert zwave_js device diagnostics to fixture (#102799) --- homeassistant/components/zwave_js/README.md | 15 +- .../components/zwave_js/scripts/__init__.py | 1 + .../convert_device_diagnostics_to_fixture.py | 91 + .../zwave_js/fixtures/device_diagnostics.json | 2315 +++++++++++++++++ .../zwave_js/fixtures/zooz_zse44_state.json | 1330 ++++++++++ tests/components/zwave_js/scripts/__init__.py | 1 + ...t_convert_device_diagnostics_to_fixture.py | 80 + 7 files changed, 3832 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/zwave_js/scripts/__init__.py create mode 100644 homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py create mode 100644 tests/components/zwave_js/fixtures/device_diagnostics.json create mode 100644 tests/components/zwave_js/fixtures/zooz_zse44_state.json create mode 100644 tests/components/zwave_js/scripts/__init__.py create mode 100644 tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py diff --git a/homeassistant/components/zwave_js/README.md b/homeassistant/components/zwave_js/README.md index f82f421f752..da49e67c60a 100644 --- a/homeassistant/components/zwave_js/README.md +++ b/homeassistant/components/zwave_js/README.md @@ -10,7 +10,20 @@ The Z-Wave integration uses a discovery mechanism to create the necessary entiti In cases where an entity's functionality requires interaction with multiple Values, the discovery rule for that particular entity type is based on the primary Value, or the Value that must be there to indicate that this entity needs to be created, and then the rest of the Values required are discovered by the class instance for that entity. A good example of this is the discovery logic for the `climate` entity. Currently, the discovery logic is tied to the discovery of a Value with a property of `mode` and a command class of `Thermostat Mode`, but the actual entity uses many more Values than that to be fully functional as evident in the [code](./climate.py). -There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data (from device diagnostics) to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). +There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). To learn how to generate fixtures, see the following section. + +### Generating device fixtures + +To generate a device fixture, download a diagnostics dump of the device from your Home Assistant instance. The dumped data will need to be modified to match the expected format. You can always do this transformation by hand, but the integration provides a [helper script](scripts/convert_device_diagnostics_to_fixture.py) that will generate the appropriate fixture data from a device diagnostics dump for you. To use it, run the script with the path to the diagnostics dump you downloaded: + +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py ` + +The script will print the fixture data to standard output, and you can use Unix piping to create a file from the fixture data: + +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py > ` + +You can alternatively pass the `--file` flag to the script and it will create the file for you in the [fixtures folder](../../../tests/components/zwave_js/fixtures): +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py --file` ### Switching HA support for a device from one entity type to another. diff --git a/homeassistant/components/zwave_js/scripts/__init__.py b/homeassistant/components/zwave_js/scripts/__init__.py new file mode 100644 index 00000000000..fda5d0f5c39 --- /dev/null +++ b/homeassistant/components/zwave_js/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts module for Z-Wave JS.""" diff --git a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py new file mode 100644 index 00000000000..1e8d295227f --- /dev/null +++ b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py @@ -0,0 +1,91 @@ +"""Script to convert a device diagnostics file to a fixture.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from homeassistant.util import slugify + + +def get_arguments() -> argparse.Namespace: + """Get parsed passed in arguments.""" + parser = argparse.ArgumentParser(description="Z-Wave JS Fixture generator") + parser.add_argument( + "diagnostics_file", type=Path, help="Device diagnostics file to convert" + ) + parser.add_argument( + "--file", + action="store_true", + help=( + "Dump fixture to file in fixtures folder. By default, the fixture will be " + "printed to standard output." + ), + ) + + arguments = parser.parse_args() + + return arguments + + +def get_fixtures_dir_path(data: dict) -> Path: + """Get path to fixtures directory.""" + device_config = data["deviceConfig"] + filename = slugify( + f"{device_config['manufacturer']}-{device_config['label']}_state" + ) + path = Path(__file__).parents[1] + index = path.parts.index("homeassistant") + return Path( + *path.parts[:index], + "tests", + *path.parts[index + 1 :], + "fixtures", + f"{filename}.json", + ) + + +def load_file(path: Path) -> Any: + """Load file from path.""" + return json.loads(path.read_text("utf8")) + + +def extract_fixture_data(diagnostics_data: Any) -> dict: + """Extract fixture data from file.""" + if ( + not isinstance(diagnostics_data, dict) + or "data" not in diagnostics_data + or "state" not in diagnostics_data["data"] + ): + raise ValueError("Invalid diagnostics file format") + state: dict = diagnostics_data["data"]["state"] + if isinstance(state["values"], list): + return state + values_dict: dict[str, dict] = state.pop("values") + state["values"] = list(values_dict.values()) + + return state + + +def create_fixture_file(path: Path, state_text: str) -> None: + """Create a file for the state dump in the fixtures directory.""" + path.write_text(state_text, "utf8") + + +def main() -> None: + """Run the main script.""" + args = get_arguments() + diagnostics_path: Path = args.diagnostics_file + diagnostics = load_file(diagnostics_path) + fixture_data = extract_fixture_data(diagnostics) + fixture_text = json.dumps(fixture_data, indent=2) + if args.file: + fixture_path = get_fixtures_dir_path(fixture_data) + create_fixture_file(fixture_path, fixture_text) + return + print(fixture_text) # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/tests/components/zwave_js/fixtures/device_diagnostics.json b/tests/components/zwave_js/fixtures/device_diagnostics.json new file mode 100644 index 00000000000..a206cb8353c --- /dev/null +++ b/tests/components/zwave_js/fixtures/device_diagnostics.json @@ -0,0 +1,2315 @@ +{ + "home_assistant": { + "installation_type": "Home Assistant OS", + "version": "2023.10.5", + "dev": false, + "hassio": true, + "virtualenv": false, + "python_version": "3.11.5", + "docker": true, + "arch": "aarch64", + "timezone": "America/New_York", + "os_name": "Linux", + "os_version": "6.1.56", + "supervisor": "2023.10.1", + "host_os": "Home Assistant OS 11.0", + "docker_version": "24.0.6", + "chassis": "embedded", + "run_as_root": true + }, + "custom_components": { + "pyscript": { + "version": "1.5.0", + "requirements": ["croniter==1.3.8", "watchdog==2.3.1"] + } + }, + "integration_manifest": { + "domain": "zwave_js", + "name": "Z-Wave", + "codeowners": ["@home-assistant/z-wave"], + "config_flow": true, + "dependencies": ["http", "repairs", "usb", "websocket_api"], + "documentation": "https://www.home-assistant.io/integrations/zwave_js", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["zwave_js_server"], + "quality_scale": "platinum", + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], + "usb": [ + { + "vid": "0658", + "pid": "0200", + "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"] + }, + { + "vid": "10C4", + "pid": "8A2A", + "description": "*z-wave*", + "known_devices": ["Nortek HUSBZB-1"] + } + ], + "zeroconf": ["_zwave-js-server._tcp.local."], + "is_built_in": true + }, + "data": { + "versionInfo": { + "driverVersion": "12.2.1", + "serverVersion": "1.33.0", + "minSchemaVersion": 0, + "maxSchemaVersion": 33 + }, + "entities": [ + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_heat_alarm_heat_sensor_status", + "original_name": "Heat Alarm Heat sensor status", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_weather_alarm_moisture_alarm_status", + "original_name": "Weather Alarm Moisture alarm status", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_alarmtype", + "original_name": "Alarm Type", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-alarmType", + "primary_value": null + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_alarmlevel", + "original_name": "Alarm Level", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-alarmLevel", + "primary_value": null + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_battery_level", + "original_name": "Battery level", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-level", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "level", + "property_name": "level", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_charging_status", + "original_name": "Charging status", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-chargingStatus", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "chargingStatus", + "property_name": "chargingStatus", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_recharge_or_replace", + "original_name": "Recharge or replace", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-rechargeOrReplace", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "rechargeOrReplace", + "property_name": "rechargeOrReplace", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_period_duration", + "original_name": "Node Identify - On/Off Period: Duration", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-3", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 3, + "property_key_name": "On/Off Period: Duration" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_cycle_count", + "original_name": "Node Identify - On/Off Cycle Count", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-4", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 4, + "property_key_name": "On/Off Cycle Count" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_period_on_time", + "original_name": "Node Identify - On/Off Period: On time", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-5", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 5, + "property_key_name": "On/Off Period: On time" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_indicator_value", + "original_name": "Indicator value", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-value", + "primary_value": null + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_low_battery_level", + "original_name": "Low battery level", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-isLow", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "isLow", + "property_name": "isLow", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_rechargeable", + "original_name": "Rechargeable", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-rechargeable", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "rechargeable", + "property_name": "rechargeable", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_used_as_backup", + "original_name": "Used as backup", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-backup", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "backup", + "property_name": "backup", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_overheating", + "original_name": "Overheating", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-overheating", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "overheating", + "property_name": "overheating", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_fluid_is_low", + "original_name": "Fluid is low", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-lowFluid", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "lowFluid", + "property_name": "lowFluid", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_battery_is_disconnected", + "original_name": "Battery is disconnected", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-disconnected", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "disconnected", + "property_name": "disconnected", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_battery_temperature_is_low", + "original_name": "Battery temperature is low", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-lowTemperatureStatus", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "lowTemperatureStatus", + "property_name": "lowTemperatureStatus", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_air_temperature", + "original_name": "Air temperature", + "original_device_class": "temperature", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "\u00b0F", + "value_id": "23-49-0-Air temperature", + "primary_value": { + "command_class": 49, + "command_class_name": "Multilevel Sensor", + "endpoint": 0, + "property": "Air temperature", + "property_name": "Air temperature", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_underheat_detected", + "original_name": "Underheat detected", + "original_device_class": "heat", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status", + "state_key": 6 + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_humidity", + "original_name": "Humidity", + "original_device_class": "humidity", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-49-0-Humidity", + "primary_value": { + "command_class": 49, + "command_class_name": "Multilevel Sensor", + "endpoint": 0, + "property": "Humidity", + "property_name": "Humidity", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_moisture_alarm", + "original_name": "Moisture alarm", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status", + "state_key": 2 + } + }, + { + "domain": "button", + "entity_id": "button.2nd_floor_sensor_idle_heat_sensor_status", + "original_name": "Idle Heat Alarm Heat sensor status", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status" + } + }, + { + "domain": "button", + "entity_id": "button.2nd_floor_sensor_idle_moisture_alarm_status", + "original_name": "Idle Weather Alarm Moisture alarm status", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status" + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_high_temperature_alert_reporting", + "original_name": "High Temperature Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-6", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 6, + "property_name": "High Temperature Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_low_temperature_alert_reporting", + "original_name": "Low Temperature Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-8", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 8, + "property_name": "Low Temperature Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_high_humidity_alert_reporting", + "original_name": "High Humidity Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-10", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 10, + "property_name": "High Humidity Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_low_humidity_alert_reporting", + "original_name": "Low Humidity Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-12", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 12, + "property_name": "Low Humidity Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_temperature_scale", + "original_name": "Temperature Scale", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-13", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 13, + "property_name": "Temperature Scale", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_battery_report_threshold", + "original_name": "Battery Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-1", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 1, + "property_name": "Battery Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_battery_alarm_threshold", + "original_name": "Low Battery Alarm Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-2", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 2, + "property_name": "Low Battery Alarm Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_report_threshold", + "original_name": "Temperature Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 \u00b0F/C", + "value_id": "23-112-0-3", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 3, + "property_name": "Temperature Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_report_threshold", + "original_name": "Humidity Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-4", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 4, + "property_name": "Humidity Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_high_temperature_alert_threshold", + "original_name": "High Temperature Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "\u00b0F/C", + "value_id": "23-112-0-5", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 5, + "property_name": "High Temperature Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_temperature_alert_threshold", + "original_name": "Low Temperature Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "\u00b0F/C", + "value_id": "23-112-0-7", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 7, + "property_name": "Low Temperature Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_high_humidity_alert_threshold", + "original_name": "High Humidity Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-9", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 9, + "property_name": "High Humidity Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_humidity_alert_threshold", + "original_name": "Low Humidity Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-11", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 11, + "property_name": "Low Humidity Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_offset", + "original_name": "Temperature Offset", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 \u00b0F/C", + "value_id": "23-112-0-14", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 14, + "property_name": "Temperature Offset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_offset", + "original_name": "Humidity Offset", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 %", + "value_id": "23-112-0-15", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 15, + "property_name": "Humidity Offset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_reporting_interval", + "original_name": "Temperature Reporting Interval", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "minutes", + "value_id": "23-112-0-16", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 16, + "property_name": "Temperature Reporting Interval", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_reporting_interval", + "original_name": "Humidity Reporting Interval", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "minutes", + "value_id": "23-112-0-17", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 17, + "property_name": "Humidity Reporting Interval", + "property_key": null, + "property_key_name": null + } + } + ], + "state": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 57348, + "productType": 28672, + "firmwareVersion": "1.10", + "zwavePlusVersion": 2, + "name": "2nd Floor Sensor", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zse44.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZSE44", + "description": "Temperature Humidity XS Sensor", + "devices": [ + { + "productType": 28672, + "productId": 57348 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Initiate inclusion (pairing) in the app (or web interface). Not sure how? ask@getzooz.com\nWhile the hub is looking for new devices, click the Z-Wave button 3 times as quickly as possible. The LED indicator will start flashing to confirm inclusion mode and turn off once inclusion is completed.", + "exclusion": "1. Bring the sensor within direct range of your Z-Wave hub.\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com).\n3. Click the Z-Wave button 3 times as quickly as possible.\n4. Your hub will confirm exclusion and the sensor will disappear from your controller's device list", + "reset": "When your network\u2019s primary controller is missing or otherwise inoperable, you may need to reset the device to factory settings manually. In order to complete the process, make sure the sensor is powered, then click the Z-Wave button twice and hold it the third time for 10 seconds. The LED indicator will blink continuously. Immediately after, click the Z-Wave button twice more to finalize the reset. The LED indicator will flash 3 times to confirm a successful reset", + "manual": "https://cdn.shopify.com/s/files/1/0218/7704/files/zooz-700-series-tilt-shock-xs-sensor-zse43-manual.pdf" + } + }, + "label": "ZSE44", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xe004:1.10", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "repeaters": [2], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-08-09T13:26:05.031Z", + "values": { + "23-49-0-Air temperature": { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "\u00b0F", + "stateful": true, + "secret": false + }, + "value": 69.9 + }, + "23-49-0-Humidity": { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 54 + }, + "23-112-0-1": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Battery Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Report Threshold", + "default": 5, + "min": 1, + "max": 10, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + "23-112-0-2": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Low Battery Alarm Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Battery Alarm Threshold", + "default": 20, + "min": 10, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-3": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Temperature Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Threshold", + "default": 20, + "min": 10, + "max": 100, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-4": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Humidity Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Report Threshold", + "default": 10, + "min": 1, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + "23-112-0-5": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "High Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Threshold", + "default": 120, + "min": 50, + "max": 120, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 120 + }, + "23-112-0-6": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "High Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 2 only", + "3": "0xff (on) to Lifeline and Group 2", + "4": "0x00 (off) to Group 2 only", + "5": "0x00 (off) to Lifeline and Group 2", + "6": "0xff (on) and 0x00 (off) to Group 2 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 2" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-7": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Low Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Threshold", + "default": 10, + "min": 10, + "max": 100, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-8": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Low Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 3 only", + "3": "0xff (on) to Lifeline and Group 3", + "4": "0x00 (off) to Group 3 only", + "5": "0x00 (off) to Lifeline and Group 3", + "6": "0xff (on) and 0x00 (off) to Group 3 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 3" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-9": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "High Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + "23-112-0-10": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "High Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 4 only", + "3": "0xff (on) to Lifeline and Group 4", + "4": "0x00 (off) to Group 4 only", + "5": "0x00 (off) to Lifeline and Group 4", + "6": "0xff (on) and 0x00 (off) to Group 4 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 4" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-11": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Low Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + "23-112-0-12": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Low Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 5 only", + "3": "0xff (on) to Lifeline and Group 5", + "4": "0x00 (off) to Group 5 only", + "5": "0x00 (off) to Lifeline and Group 5", + "6": "0xff (on) and 0x00 (off) to Group 5 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 5" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-13": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Temperature Scale", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + "23-112-0-14": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Temperature Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Temperature Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + "23-112-0-15": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Humidity Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Humidity Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 %", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + "23-112-0-16": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Temperature Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + "23-112-0-17": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Humidity Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + "23-113-0-Heat Alarm-Heat sensor status": { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Underheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-113-0-Weather Alarm-Moisture alarm status": { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Weather Alarm", + "propertyKey": "Moisture alarm status", + "propertyName": "Weather Alarm", + "propertyKeyName": "Moisture alarm status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Moisture alarm status", + "ccSpecific": { + "notificationType": 16 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Moisture alarm" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-114-0-manufacturerId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 634 + }, + "23-114-0-productType": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 28672 + }, + "23-114-0-productId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 57348 + }, + "23-128-0-level": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-128-0-isLow": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": true + }, + "23-128-0-chargingStatus": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + } + }, + "23-128-0-rechargeable": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + } + }, + "23-128-0-backup": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + } + }, + "23-128-0-overheating": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + } + }, + "23-128-0-lowFluid": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + } + }, + "23-128-0-rechargeOrReplace": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + } + }, + "23-128-0-disconnected": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + } + }, + "23-128-0-lowTemperatureStatus": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowTemperatureStatus", + "propertyName": "lowTemperatureStatus", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery temperature is low", + "stateful": true, + "secret": false + } + }, + "23-132-0-wakeUpInterval": { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 1, + "metadata": { + "type": "number", + "default": 21600, + "readable": false, + "writeable": true, + "min": 3600, + "max": 86400, + "steps": 60, + "stateful": true, + "secret": false + }, + "value": 21600 + }, + "23-132-0-controllerNodeId": { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + "23-134-0-libraryType": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + "23-134-0-protocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.13" + }, + "23-134-0-firmwareVersions": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.10"] + }, + "23-134-0-hardwareVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + "23-134-0-sdkVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationFrameworkAPIVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationFrameworkBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-hostInterfaceVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + } + }, + "23-134-0-hostInterfaceBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-zWaveProtocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + }, + "23-134-0-zWaveProtocolBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + } + }, + "23-135-0-80-3": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-135-0-80-4": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-135-0-80-5": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + } + }, + "endpoints": { + "0": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 3, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 7, + "isSecure": true + } + ] + } + } + } + } +} diff --git a/tests/components/zwave_js/fixtures/zooz_zse44_state.json b/tests/components/zwave_js/fixtures/zooz_zse44_state.json new file mode 100644 index 00000000000..a2fb5421fb7 --- /dev/null +++ b/tests/components/zwave_js/fixtures/zooz_zse44_state.json @@ -0,0 +1,1330 @@ +{ + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 57348, + "productType": 28672, + "firmwareVersion": "1.10", + "zwavePlusVersion": 2, + "name": "2nd Floor Sensor", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zse44.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZSE44", + "description": "Temperature Humidity XS Sensor", + "devices": [ + { + "productType": 28672, + "productId": 57348 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Initiate inclusion (pairing) in the app (or web interface). Not sure how? ask@getzooz.com\nWhile the hub is looking for new devices, click the Z-Wave button 3 times as quickly as possible. The LED indicator will start flashing to confirm inclusion mode and turn off once inclusion is completed.", + "exclusion": "1. Bring the sensor within direct range of your Z-Wave hub.\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com).\n3. Click the Z-Wave button 3 times as quickly as possible.\n4. Your hub will confirm exclusion and the sensor will disappear from your controller's device list", + "reset": "When your network\u2019s primary controller is missing or otherwise inoperable, you may need to reset the device to factory settings manually. In order to complete the process, make sure the sensor is powered, then click the Z-Wave button twice and hold it the third time for 10 seconds. The LED indicator will blink continuously. Immediately after, click the Z-Wave button twice more to finalize the reset. The LED indicator will flash 3 times to confirm a successful reset", + "manual": "https://cdn.shopify.com/s/files/1/0218/7704/files/zooz-700-series-tilt-shock-xs-sensor-zse43-manual.pdf" + } + }, + "label": "ZSE44", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xe004:1.10", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "repeaters": [2], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-08-09T13:26:05.031Z", + "endpoints": { + "0": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 3, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 7, + "isSecure": true + } + ] + } + }, + "values": [ + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "\u00b0F", + "stateful": true, + "secret": false + }, + "value": 69.9 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 54 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Battery Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Report Threshold", + "default": 5, + "min": 1, + "max": 10, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Low Battery Alarm Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Battery Alarm Threshold", + "default": 20, + "min": 10, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Temperature Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Threshold", + "default": 20, + "min": 10, + "max": 100, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Humidity Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Report Threshold", + "default": 10, + "min": 1, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "High Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Threshold", + "default": 120, + "min": 50, + "max": 120, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 120 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "High Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 2 only", + "3": "0xff (on) to Lifeline and Group 2", + "4": "0x00 (off) to Group 2 only", + "5": "0x00 (off) to Lifeline and Group 2", + "6": "0xff (on) and 0x00 (off) to Group 2 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 2" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Low Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Threshold", + "default": 10, + "min": 10, + "max": 100, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Low Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 3 only", + "3": "0xff (on) to Lifeline and Group 3", + "4": "0x00 (off) to Group 3 only", + "5": "0x00 (off) to Lifeline and Group 3", + "6": "0xff (on) and 0x00 (off) to Group 3 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 3" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "High Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "High Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 4 only", + "3": "0xff (on) to Lifeline and Group 4", + "4": "0x00 (off) to Group 4 only", + "5": "0x00 (off) to Lifeline and Group 4", + "6": "0xff (on) and 0x00 (off) to Group 4 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 4" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Low Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Low Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 5 only", + "3": "0xff (on) to Lifeline and Group 5", + "4": "0x00 (off) to Group 5 only", + "5": "0x00 (off) to Lifeline and Group 5", + "6": "0xff (on) and 0x00 (off) to Group 5 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 5" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Temperature Scale", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Temperature Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Temperature Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Humidity Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Humidity Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 %", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Temperature Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Humidity Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Underheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Weather Alarm", + "propertyKey": "Moisture alarm status", + "propertyName": "Weather Alarm", + "propertyKeyName": "Moisture alarm status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Moisture alarm status", + "ccSpecific": { + "notificationType": 16 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Moisture alarm" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 634 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 28672 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 57348 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowTemperatureStatus", + "propertyName": "lowTemperatureStatus", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery temperature is low", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 1, + "metadata": { + "type": "number", + "default": 21600, + "readable": false, + "writeable": true, + "min": 3600, + "max": 86400, + "steps": 60, + "stateful": true, + "secret": false + }, + "value": 21600 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.13" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.10"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + } + ] +} diff --git a/tests/components/zwave_js/scripts/__init__.py b/tests/components/zwave_js/scripts/__init__.py new file mode 100644 index 00000000000..96d81d993e9 --- /dev/null +++ b/tests/components/zwave_js/scripts/__init__.py @@ -0,0 +1 @@ +"""Tests for zwave_js scripts.""" diff --git a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py new file mode 100644 index 00000000000..d1e12e7abb4 --- /dev/null +++ b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py @@ -0,0 +1,80 @@ +"""Test convert_device_diagnostics_to_fixture script.""" +import copy +import json +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +from homeassistant.components.zwave_js.scripts.convert_device_diagnostics_to_fixture import ( + extract_fixture_data, + get_fixtures_dir_path, + load_file, + main, +) + +from tests.common import load_fixture + + +def _minify(text: str) -> str: + """Minify string by removing whitespace and new lines.""" + return text.replace(" ", "").replace("\n", "") + + +def test_fixture_functions() -> None: + """Test functions related to the fixture.""" + diagnostics_data = json.loads(load_fixture("zwave_js/device_diagnostics.json")) + state = extract_fixture_data(copy.deepcopy(diagnostics_data)) + assert isinstance(state["values"], list) + assert ( + get_fixtures_dir_path(state) + == Path(__file__).parents[1] / "fixtures" / "zooz_zse44_state.json" + ) + + old_diagnostics_format_data = copy.deepcopy(diagnostics_data) + old_diagnostics_format_data["data"]["state"]["values"] = list( + old_diagnostics_format_data["data"]["state"]["values"].values() + ) + assert ( + extract_fixture_data(old_diagnostics_format_data) + == old_diagnostics_format_data["data"]["state"] + ) + + with pytest.raises(ValueError): + extract_fixture_data({}) + + +def test_load_file() -> None: + """Test load file.""" + assert load_file( + Path(__file__).parents[1] / "fixtures" / "device_diagnostics.json" + ) == json.loads(load_fixture("zwave_js/device_diagnostics.json")) + + +def test_main(capfd: pytest.CaptureFixture[str]) -> None: + """Test main function.""" + Path(__file__).parents[1] / "fixtures" / "zooz_zse44_state.json" + fixture_str = load_fixture("zwave_js/zooz_zse44_state.json") + fixture_dict = json.loads(fixture_str) + + # Test dump to stdout + args = [ + "homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py", + str(Path(__file__).parents[1] / "fixtures" / "device_diagnostics.json"), + ] + with patch.object(sys, "argv", args): + main() + + captured = capfd.readouterr() + assert _minify(captured.out) == _minify(fixture_str) + + # Check file dump + args.append("--file") + with patch.object(sys, "argv", args), patch( + "homeassistant.components.zwave_js.scripts.convert_device_diagnostics_to_fixture.Path.write_text" + ) as write_text_mock: + main() + + assert len(write_text_mock.call_args_list) == 1 + assert write_text_mock.call_args[0][0] == json.dumps(fixture_dict, indent=2) From 371a49d2f4742f19f222bbc471701a620ad1e8b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Oct 2023 15:55:28 -0500 Subject: [PATCH 003/982] Bump HAP-python 4.9.1 (#102811) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 4f6cc24edc8..17d1237e579 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.9.0", + "HAP-python==4.9.1", "fnv-hash-fast==0.5.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index 7d5f24f0b82..081db1b1938 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.9.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e46c62e8976..1e224fb9f32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.9.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 From 27ac2ceae3249871021a5666831fb65225b44a34 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Oct 2023 23:09:36 +0200 Subject: [PATCH 004/982] Fix velbus import (#102780) --- homeassistant/components/velbus/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 5c35303f859..1888a177895 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -import velbusaio +import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol From f9712627c53b086aae4a97e5a307338886828dbc Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 25 Oct 2023 23:53:14 +0200 Subject: [PATCH 005/982] Add myself as a code owner for ZHA (#102812) Add 'TheJulianJES' as a code owner for ZHA --- CODEOWNERS | 4 ++-- homeassistant/components/zha/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b9cce3b9047..a9e666f76ca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1501,8 +1501,8 @@ build.json @home-assistant/supervisor /tests/components/zerproc/ @emlove /homeassistant/components/zeversolar/ @kvanzuijlen /tests/components/zeversolar/ @kvanzuijlen -/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly -/tests/components/zha/ @dmulcahey @adminiuga @puddly +/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES +/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6efee0e96ac..af2c8405e5f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -2,7 +2,7 @@ "domain": "zha", "name": "Zigbee Home Automation", "after_dependencies": ["onboarding", "usb"], - "codeowners": ["@dmulcahey", "@adminiuga", "@puddly"], + "codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"], "config_flow": true, "dependencies": ["file_upload"], "documentation": "https://www.home-assistant.io/integrations/zha", From 69a0c0d43503e57d9ed3680637702e774ef8ec6b Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 25 Oct 2023 17:57:47 -0400 Subject: [PATCH 006/982] Move coordinator first refresh in Blink (#102805) Move coordinator first refresh --- homeassistant/components/blink/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 89438c9c7c1..c6413dd4372 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -86,8 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: blink.auth = Auth(auth_data, no_prompt=True, session=session) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) coordinator = BlinkUpdateCoordinator(hass, blink) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator try: await blink.start() @@ -101,6 +99,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not blink.available: raise ConfigEntryNotReady + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) From 64f0ea60d1f09408e600591b2cae28a1b15387af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 01:08:31 +0200 Subject: [PATCH 007/982] Correct logic for picking bluetooth local name (#102823) * Correct logic for picking bluetooth local name * make test more robust --------- Co-authored-by: J. Nick Koston --- .../components/bluetooth/base_scanner.py | 2 +- .../components/bluetooth/test_base_scanner.py | 31 +++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 240610e4868..8eacd3e291a 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -330,7 +330,7 @@ class BaseHaRemoteScanner(BaseHaScanner): prev_manufacturer_data = prev_advertisement.manufacturer_data prev_name = prev_device.name - if local_name and prev_name and len(prev_name) > len(local_name): + if prev_name and (not local_name or len(prev_name) > len(local_name)): local_name = prev_name if service_uuids and service_uuids != prev_service_uuids: diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index fc870f2bfe3..31d90a6e93d 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -42,7 +42,10 @@ from . import ( from tests.common import async_fire_time_changed, load_fixture -async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.parametrize("name_2", [None, "w"]) +async def test_remote_scanner( + hass: HomeAssistant, enable_bluetooth: None, name_2: str | None +) -> None: """Test the remote scanner base class merges advertisement_data.""" manager = _get_manager() @@ -61,12 +64,25 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No ) switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", - "w", + name_2, {}, rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( - local_name="wohand", + local_name=name_2, + service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], + service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01", 2: b"\x02"}, + rssi=-100, + ) + switchbot_device_3 = generate_ble_device( + "44:44:33:11:23:45", + "wohandlonger", + {}, + rssi=-100, + ) + switchbot_device_adv_3 = generate_advertisement_data( + local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, @@ -125,6 +141,15 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No "00000001-0000-1000-8000-00805f9b34fb", } + # The longer name should be used + scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3) + assert discovered_device.name == switchbot_device_3.name + + # Inject the shorter name / None again to make + # sure we always keep the longer name + scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) + assert discovered_device.name == switchbot_device_3.name + cancel() unsetup() From e5078a3e1363c343d89486f050bc6cefd0f77bba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 05:20:50 +0200 Subject: [PATCH 008/982] Use real devices in automation blueprint tests (#102824) --- tests/components/automation/test_blueprint.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index ad35a2cfbdd..2976886881d 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -7,13 +7,15 @@ from unittest.mock import patch import pytest +from homeassistant import config_entries from homeassistant.components import automation from homeassistant.components.blueprint import models from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, yaml -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(automation.__file__).parent / "blueprints" @@ -40,8 +42,18 @@ def patch_blueprint(blueprint_path: str, data_path): yield -async def test_notify_leaving_zone(hass: HomeAssistant) -> None: +async def test_notify_leaving_zone( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test notifying leaving a zone blueprint.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) def set_person_state(state, extra={}): hass.states.async_set( @@ -68,7 +80,7 @@ async def test_notify_leaving_zone(hass: HomeAssistant) -> None: "input": { "person_entity": "person.test_person", "zone_entity": "zone.school", - "notify_device": "abcdefgh", + "notify_device": device.id, }, } } @@ -89,7 +101,7 @@ async def test_notify_leaving_zone(hass: HomeAssistant) -> None: "alias": "Notify that a person has left the zone", "domain": "mobile_app", "type": "notify", - "device_id": "abcdefgh", + "device_id": device.id, } message_tpl.hass = hass assert message_tpl.async_render(variables) == "Paulus has left School" From 43ac77ca2f788e2f96562a50d2565e5939c0f02a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 05:22:38 +0200 Subject: [PATCH 009/982] Fix fan device actions (#102797) --- homeassistant/components/fan/device_action.py | 14 ++++++++++++-- tests/components/fan/test_device_action.py | 3 +++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index 55bd862349b..fc7f1ddce1f 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -3,14 +3,24 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import CONF_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) +_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_get_actions( diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 3b179bc158c..b8756d9ace5 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -171,6 +171,7 @@ async def test_action( hass.bus.async_fire("test_event_turn_off") await hass.async_block_till_done() assert len(turn_off_calls) == 1 + assert turn_off_calls[0].data["entity_id"] == entry.entity_id assert len(turn_on_calls) == 0 assert len(toggle_calls) == 0 @@ -178,6 +179,7 @@ async def test_action( await hass.async_block_till_done() assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 + assert turn_on_calls[0].data["entity_id"] == entry.entity_id assert len(toggle_calls) == 0 hass.bus.async_fire("test_event_toggle") @@ -185,6 +187,7 @@ async def test_action( assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 assert len(toggle_calls) == 1 + assert toggle_calls[0].data["entity_id"] == entry.entity_id async def test_action_legacy( From b89e7a2fe29d0a8fb604b2170a28b77455f74891 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Oct 2023 22:23:06 -0500 Subject: [PATCH 010/982] Bump bleak-retry-connector to 3.3.0 (#102825) changelog: https://github.com/Bluetooth-Devices/bleak-retry-connector/compare/v3.2.1...v3.3.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 960a86637ae..06e7d34e68d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.2.1", + "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.13.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index def5f0c9afa..3aff4601d45 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 bcrypt==4.0.1 -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 diff --git a/requirements_all.txt b/requirements_all.txt index 081db1b1938..d934d75484d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -530,7 +530,7 @@ bimmer-connected==0.14.2 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e224fb9f32..401f68e2b5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ bellows==0.36.8 bimmer-connected==0.14.2 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 From 69399706249d6ceddc64725d361ae5d0a3edbef0 Mon Sep 17 00:00:00 2001 From: Robert Van Gorkom Date: Wed, 25 Oct 2023 20:25:23 -0700 Subject: [PATCH 011/982] Remove code owner. (#102829) --- CODEOWNERS | 4 ++-- homeassistant/components/econet/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index a9e666f76ca..b77916932d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -311,8 +311,8 @@ build.json @home-assistant/supervisor /tests/components/ecobee/ @marthoc @marcolivierarsenault /homeassistant/components/ecoforest/ @pjanuario /tests/components/ecoforest/ @pjanuario -/homeassistant/components/econet/ @vangorra @w1ll1am23 -/tests/components/econet/ @vangorra @w1ll1am23 +/homeassistant/components/econet/ @w1ll1am23 +/tests/components/econet/ @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT @mib1185 /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 3472ca231e9..55f40112e65 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -1,7 +1,7 @@ { "domain": "econet", "name": "Rheem EcoNet Products", - "codeowners": ["@vangorra", "@w1ll1am23"], + "codeowners": ["@w1ll1am23"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", From dd28d1e17f8b219d9751399879e7336298ce4e9c Mon Sep 17 00:00:00 2001 From: William Scanlon <6432770+w1ll1am23@users.noreply.github.com> Date: Wed, 25 Oct 2023 23:25:44 -0400 Subject: [PATCH 012/982] Bump pyeconet to 0.1.22 to handle breaking API change (#102820) --- homeassistant/components/econet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 55f40112e65..c96867b489b 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.20"] + "requirements": ["pyeconet==0.1.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index d934d75484d..ff3ab55c58d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ pyebox==1.1.4 pyecoforest==0.3.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.edimax pyedimax==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 401f68e2b5e..b619208d0a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1269,7 +1269,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.3.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.efergy pyefergy==22.1.1 From aa67542ef801e8423d619709cd5b443a985eda5e Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Thu, 26 Oct 2023 05:26:10 +0200 Subject: [PATCH 013/982] Bump homematicip to 1.0.16 (#102822) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index c3d14b7d383..d75ca02b66f 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.15"] + "requirements": ["homematicip==1.0.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index ff3ab55c58d..49c8c2ea81d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1016,7 +1016,7 @@ home-assistant-intents==2023.10.16 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.15 +homematicip==1.0.16 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b619208d0a7..99635bb1b94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -805,7 +805,7 @@ home-assistant-intents==2023.10.16 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.15 +homematicip==1.0.16 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 From 4838b2dee6d619ed521f09e74e65b7a34af927dc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 26 Oct 2023 20:19:31 +1300 Subject: [PATCH 014/982] ESPHome: Add suggested_area from device info (#102834) --- homeassistant/components/esphome/manager.py | 5 +++++ homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 812cf430d09..d2eca7d39f9 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -596,6 +596,10 @@ def _async_setup_device_registry( model = project_name[1] hw_version = device_info.project_version + suggested_area = None + if device_info.suggested_area: + suggested_area = device_info.suggested_area + device_registry = dr.async_get(hass) device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -606,6 +610,7 @@ def _async_setup_device_registry( model=model, sw_version=sw_version, hw_version=hw_version, + suggested_area=suggested_area, ) return device_entry.id diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 702f75b166e..8968fa7da4f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.1.0", + "aioesphomeapi==18.2.0", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 49c8c2ea81d..bf62434eda7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.1.0 +aioesphomeapi==18.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99635bb1b94..438b934f5a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.1.0 +aioesphomeapi==18.2.0 # homeassistant.components.flo aioflo==2021.11.0 From 087df10d27bb370d5fae686c254b65a30daafdcc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 09:46:16 +0200 Subject: [PATCH 015/982] Improve validation of device automations (#102766) * Improve validation of device automations * Improve comments * Address review comment --- .../components/device_automation/helpers.py | 37 +++-- .../components/device_automation/test_init.py | 148 +++++++++++++----- 2 files changed, 133 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 83c599bc65d..a00455293f6 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -5,9 +5,9 @@ from typing import cast import voluptuous as vol -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -55,31 +55,42 @@ async def async_validate_device_automation_config( platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) + + # Make sure the referenced device and optional entity exist + device_registry = dr.async_get(hass) + if not (device := device_registry.async_get(validated_config[CONF_DEVICE_ID])): + # The device referenced by the device automation does not exist + raise InvalidDeviceAutomationConfig( + f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" + ) + if entity_id := validated_config.get(CONF_ENTITY_ID): + try: + er.async_validate_entity_id(er.async_get(hass), entity_id) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig( + f"Unknown entity '{entity_id}'" + ) from err + if not hasattr(platform, DYNAMIC_VALIDATOR[automation_type]): # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config) ) - # Bypass checks for entity platforms + # Devices are not linked to config entries from entity platform domains, skip + # the checks below which look for a config entry matching the device automation + # domain if ( automation_type == DeviceAutomationType.ACTION and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS ): + # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, await getattr(platform, DYNAMIC_VALIDATOR[automation_type])(hass, config), ) - # Only call the dynamic validator if the referenced device exists and the relevant - # config entry is loaded - registry = dr.async_get(hass) - if not (device := registry.async_get(validated_config[CONF_DEVICE_ID])): - # The device referenced by the device automation does not exist - raise InvalidDeviceAutomationConfig( - f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" - ) - + # Find a config entry with the same domain as the device automation device_config_entry = None for entry_id in device.config_entries: if ( @@ -91,7 +102,7 @@ async def async_validate_device_automation_config( break if not device_config_entry: - # The config entry referenced by the device automation does not exist + # There's no config entry with the same domain as the device automation raise InvalidDeviceAutomationConfig( f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from " f"domain '{validated_config[CONF_DOMAIN]}'" diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 3a7105684f4..457b7ccbf9b 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,6 +1,7 @@ """The test for light device automation.""" from unittest.mock import AsyncMock, Mock, patch +import attr import pytest from pytest_unordered import unordered import voluptuous as vol @@ -31,6 +32,13 @@ from tests.common import ( from tests.typing import WebSocketGenerator +@attr.s(frozen=True) +class MockDeviceEntry(dr.DeviceEntry): + """Device Registry Entry with fixed UUID.""" + + id: str = attr.ib(default="very_unique") + + @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" @@ -1240,17 +1248,56 @@ async def test_automation_with_integration_without_device_trigger( ) +BAD_AUTOMATIONS = [ + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "wrong"}, + "required key not provided @ data{path}['domain']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "very_unique", "domain": "light", "entity_id": "wrong"}, + "Unknown entity 'wrong'", + ), +] + +BAD_TRIGGERS = BAD_CONDITIONS = BAD_AUTOMATIONS + [ + ( + {"domain": "light"}, + "required key not provided @ data{path}['device_id']", + ) +] + + +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("action", "expected_error"), BAD_AUTOMATIONS) async def test_automation_with_bad_action( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + action: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" config_entry = MockConfigEntry(domain="fake_integration", data={}) config_entry.state = config_entries.ConfigEntryState.LOADED config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -1262,25 +1309,29 @@ async def test_automation_with_bad_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"device_id": device_entry.id, "domain": "light"}, + "action": action, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition_action( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" config_entry = MockConfigEntry(domain="fake_integration", data={}) config_entry.state = config_entries.ConfigEntryState.LOADED config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -1292,42 +1343,32 @@ async def test_automation_with_bad_condition_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": { - "condition": "device", - "device_id": device_entry.id, - "domain": "light", - }, + "action": {"condition": "device"} | condition, } }, ) - assert "required key not provided" in caplog.text - - -async def test_automation_with_bad_condition_missing_domain( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test automation with bad device condition.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "device_id": "hello.device"}, - "action": {"service": "test.automation", "entity_id": "hello.world"}, - } - }, - ) - - assert "required key not provided @ data['condition'][0]['domain']" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1335,13 +1376,13 @@ async def test_automation_with_bad_condition( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "domain": "light"}, + "condition": {"condition": "device"} | condition, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['condition'][0]") in caplog.text @pytest.fixture @@ -1475,10 +1516,24 @@ async def test_automation_with_sub_condition( ) +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_sub_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition under and/or conditions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1488,33 +1543,48 @@ async def test_automation_with_bad_sub_condition( "trigger": {"platform": "event", "event_type": "test_event1"}, "condition": { "condition": "and", - "conditions": [{"condition": "device", "domain": "light"}], + "conditions": [{"condition": "device"} | condition], }, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + path = "['condition'][0]['conditions'][0]" + assert expected_error.format(path=path) in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("trigger", "expected_error"), BAD_TRIGGERS) async def test_automation_with_bad_trigger( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + trigger: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device trigger.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { "alias": "hello", - "trigger": {"platform": "device", "domain": "light"}, + "trigger": {"platform": "device"} | trigger, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="") in caplog.text async def test_websocket_device_not_found( From edf2e42e4d8aaae74bd18f3b75be01dffa3ebab3 Mon Sep 17 00:00:00 2001 From: Amit Finkelstein Date: Thu, 26 Oct 2023 11:46:20 +0300 Subject: [PATCH 016/982] Apple TV: Use replacement commands for deprecated ones (#102056) Co-authored-by: Robert Resch --- homeassistant/components/apple_tv/remote.py | 17 ++++++++++++- tests/components/apple_tv/test_remote.py | 28 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/components/apple_tv/test_remote.py diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index f3be6977891..bab3421c58d 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -21,6 +21,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +COMMAND_TO_ATTRIBUTE = { + "wakeup": ("power", "turn_on"), + "suspend": ("power", "turn_off"), + "turn_on": ("power", "turn_on"), + "turn_off": ("power", "turn_off"), + "volume_up": ("audio", "volume_up"), + "volume_down": ("audio", "volume_down"), + "home_hold": ("remote_control", "home"), +} async def async_setup_entry( @@ -61,7 +70,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): for _ in range(num_repeats): for single_command in command: - attr_value = getattr(self.atv.remote_control, single_command, None) + attr_value = None + if attributes := COMMAND_TO_ATTRIBUTE.get(single_command): + attr_value = self.atv + for attr_name in attributes: + attr_value = getattr(attr_value, attr_name, None) + if not attr_value: + attr_value = getattr(self.atv.remote_control, single_command, None) if not attr_value: raise ValueError("Command not found. Exiting sequence") diff --git a/tests/components/apple_tv/test_remote.py b/tests/components/apple_tv/test_remote.py new file mode 100644 index 00000000000..db2a4964f6c --- /dev/null +++ b/tests/components/apple_tv/test_remote.py @@ -0,0 +1,28 @@ +"""Test apple_tv remote.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.apple_tv.remote import AppleTVRemote +from homeassistant.components.remote import ATTR_DELAY_SECS, ATTR_NUM_REPEATS + + +@pytest.mark.parametrize( + ("command", "method"), + [ + ("up", "remote_control.up"), + ("wakeup", "power.turn_on"), + ("volume_up", "audio.volume_up"), + ("home_hold", "remote_control.home"), + ], + ids=["up", "wakeup", "volume_up", "home_hold"], +) +async def test_send_command(command: str, method: str) -> None: + """Test "send_command" method.""" + remote = AppleTVRemote("test", "test", None) + remote.atv = AsyncMock() + await remote.async_send_command( + [command], **{ATTR_NUM_REPEATS: 1, ATTR_DELAY_SECS: 0} + ) + assert len(remote.atv.method_calls) == 1 + assert str(remote.atv.method_calls[0]) == f"call.{method}()" From 36c6f426df33d66d382243e1f394fc0447a041a6 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Thu, 26 Oct 2023 02:59:48 -0700 Subject: [PATCH 017/982] Bump screenlogicpy to v0.9.4 (#102836) --- homeassistant/components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index e61ca04374f..69bed1af700 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.3"] + "requirements": ["screenlogicpy==0.9.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf62434eda7..89abaca7f30 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2382,7 +2382,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 438b934f5a9..7d55f675f53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1769,7 +1769,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.backup securetar==2023.3.0 From 1a1bc05470c431094cb6f48c38c95aa7444de2a0 Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Thu, 26 Oct 2023 19:12:18 +0900 Subject: [PATCH 018/982] Address late review of switchbot cloud (#102842) For Martin's review --- homeassistant/components/switchbot_cloud/climate.py | 8 +++++--- homeassistant/components/switchbot_cloud/switch.py | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 8ad0e1ad43f..803669c806d 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType from . import SwitchbotCloudData from .const import DOMAIN @@ -44,7 +43,6 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] @@ -55,7 +53,10 @@ async def async_setup_entry( class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): - """Representation of a SwitchBot air conditionner, as it is an IR device, we don't know the actual state.""" + """Representation of a SwitchBot air conditionner. + + As it is an IR device, we don't know the actual state. + """ _attr_assumed_state = True _attr_supported_features = ( @@ -116,3 +117,4 @@ class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): return await self._do_send_command(temperature=temperature) self._attr_target_temperature = temperature + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index c63b1713b8d..4f2cdc22ba9 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -7,7 +7,6 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType from . import SwitchbotCloudData from .const import DOMAIN @@ -19,7 +18,6 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] From 9ea97fd8d25e52cd02a5e0fb44b7717f7238584d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 14:00:44 +0200 Subject: [PATCH 019/982] Improve docstrings for time related event helpers (#102839) * Improve docstrings for time related event helpers * Fix grammar --------- Co-authored-by: Martin Hjelmare --- homeassistant/helpers/event.py | 35 +++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 2da8a48be98..75e2340a187 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1357,7 +1357,10 @@ def async_track_point_in_time( | Callable[[datetime], Coroutine[Any, Any, None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: - """Add a listener that fires once after a specific point in time.""" + """Add a listener that fires once at or after a specific point in time. + + The listener is passed the time it fires in local time. + """ job = ( action if isinstance(action, HassJob) @@ -1388,7 +1391,10 @@ def async_track_point_in_utc_time( | Callable[[datetime], Coroutine[Any, Any, None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: - """Add a listener that fires once after a specific point in UTC time.""" + """Add a listener that fires once at or after a specific point in time. + + The listener is passed the time it fires in UTC time. + """ # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) @@ -1450,7 +1456,10 @@ def async_call_at( | Callable[[datetime], Coroutine[Any, Any, None] | None], loop_time: float, ) -> CALLBACK_TYPE: - """Add a listener that is called at .""" + """Add a listener that fires at or after . + + The listener is passed the time it fires in UTC time. + """ job = ( action if isinstance(action, HassJob) @@ -1467,7 +1476,10 @@ def async_call_later( action: HassJob[[datetime], Coroutine[Any, Any, None] | None] | Callable[[datetime], Coroutine[Any, Any, None] | None], ) -> CALLBACK_TYPE: - """Add a listener that is called in .""" + """Add a listener that fires at or after . + + The listener is passed the time it fires in UTC time. + """ if isinstance(delay, timedelta): delay = delay.total_seconds() job = ( @@ -1492,7 +1504,10 @@ def async_track_time_interval( name: str | None = None, cancel_on_shutdown: bool | None = None, ) -> CALLBACK_TYPE: - """Add a listener that fires repetitively at every timedelta interval.""" + """Add a listener that fires repetitively at every timedelta interval. + + The listener is passed the time it fires in UTC time. + """ remove: CALLBACK_TYPE interval_listener_job: HassJob[[datetime], None] interval_seconds = interval.total_seconds() @@ -1636,7 +1651,10 @@ def async_track_utc_time_change( second: Any | None = None, local: bool = False, ) -> CALLBACK_TYPE: - """Add a listener that will fire if time matches a pattern.""" + """Add a listener that will fire every time the UTC or local time matches a pattern. + + The listener is passed the time it fires in UTC or local time. + """ # We do not have to wrap the function with time pattern matching logic # if no pattern given if all(val is None or val == "*" for val in (hour, minute, second)): @@ -1711,7 +1729,10 @@ def async_track_time_change( minute: Any | None = None, second: Any | None = None, ) -> CALLBACK_TYPE: - """Add a listener that will fire if local time matches a pattern.""" + """Add a listener that will fire every time the local time matches a pattern. + + The listener is passed the time it fires in local time. + """ return async_track_utc_time_change(hass, action, hour, minute, second, local=True) From cf03f8338a4ea1211d0cdbafa69efbcdae806106 Mon Sep 17 00:00:00 2001 From: nachonam Date: Thu, 26 Oct 2023 14:35:51 +0200 Subject: [PATCH 020/982] Add Freebox Home alarm panel (#102607) * add alarm control panel * optimize update node * Modify comment * move const to alarm * add alarm panel tests * tests modified * add file into coveragerc * Review: DATA_HOME_GET_VALUES -> DATA_HOME_PIR_GET_VALUES * Review: commands rename * Review: precise what "alarm2" is for features * Review: remove custom attributes & properties that exists in parent * Review: Avoid duplicates of async_write_ha_state() * make functions private * Review: initial state never works * Review: remove extra attrs * Review: fix tests * Fix tests * Remove line in .coveragerc --------- Co-authored-by: Quentame --- .../components/freebox/alarm_control_panel.py | 138 +++++++ homeassistant/components/freebox/const.py | 2 + tests/components/freebox/conftest.py | 4 +- tests/components/freebox/const.py | 361 +++++++++++++++++- .../freebox/test_alarm_control_panel.py | 123 ++++++ .../components/freebox/test_binary_sensor.py | 4 +- 6 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/freebox/alarm_control_panel.py create mode 100644 tests/components/freebox/test_alarm_control_panel.py diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py new file mode 100644 index 00000000000..52b7109045c --- /dev/null +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -0,0 +1,138 @@ +"""Support for Freebox alarms.""" +import logging +from typing import Any + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, FreeboxHomeCategory +from .home_base import FreeboxHomeEntity +from .router import FreeboxRouter + +FREEBOX_TO_STATUS = { + "alarm1_arming": STATE_ALARM_ARMING, + "alarm2_arming": STATE_ALARM_ARMING, + "alarm1_armed": STATE_ALARM_ARMED_AWAY, + "alarm2_armed": STATE_ALARM_ARMED_NIGHT, + "alarm1_alert_timer": STATE_ALARM_TRIGGERED, + "alarm2_alert_timer": STATE_ALARM_TRIGGERED, + "alert": STATE_ALARM_TRIGGERED, +} + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up alarm panel.""" + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + + alarm_entities: list[AlarmControlPanelEntity] = [] + + for node in router.home_devices.values(): + if node["category"] == FreeboxHomeCategory.ALARM: + alarm_entities.append(FreeboxAlarm(hass, router, node)) + + if alarm_entities: + async_add_entities(alarm_entities, True) + + +class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): + """Representation of a Freebox alarm.""" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize an alarm.""" + super().__init__(hass, router, node) + + # Commands + self._command_trigger = self.get_command_id( + node["type"]["endpoints"], "slot", "trigger" + ) + self._command_arm_away = self.get_command_id( + node["type"]["endpoints"], "slot", "alarm1" + ) + self._command_arm_home = self.get_command_id( + node["type"]["endpoints"], "slot", "alarm2" + ) + self._command_disarm = self.get_command_id( + node["type"]["endpoints"], "slot", "off" + ) + self._command_state = self.get_command_id( + node["type"]["endpoints"], "signal", "state" + ) + self._set_features(self._router.home_devices[self._id]) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + if await self.set_home_endpoint_value(self._command_disarm): + self._set_state(STATE_ALARM_DISARMED) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + if await self.set_home_endpoint_value(self._command_arm_away): + self._set_state(STATE_ALARM_ARMING) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + if await self.set_home_endpoint_value(self._command_arm_home): + self._set_state(STATE_ALARM_ARMING) + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + if await self.set_home_endpoint_value(self._command_trigger): + self._set_state(STATE_ALARM_TRIGGERED) + + async def async_update_signal(self): + """Update signal.""" + state = await self.get_home_endpoint_value(self._command_state) + if state: + self._set_state(state) + + def _set_features(self, node: dict[str, Any]) -> None: + """Add alarm features.""" + # Search if the arm home feature is present => has an "alarm2" endpoint + can_arm_home = False + for nodeid, local_node in self._router.home_devices.items(): + if nodeid == local_node["id"]: + alarm2 = next( + filter( + lambda x: (x["name"] == "alarm2" and x["ep_type"] == "signal"), + local_node["show_endpoints"], + ), + None, + ) + if alarm2: + can_arm_home = alarm2["value"] + break + + if can_arm_home: + self._attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + ) + + else: + self._attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + + def _set_state(self, state: str) -> None: + """Update state.""" + self._attr_state = FREEBOX_TO_STATUS.get(state) + if not self._attr_state: + self._attr_state = STATE_ALARM_DISARMED + self.async_write_ha_state() diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 0c3450d13b6..f74f6f49ebf 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -18,6 +18,7 @@ APP_DESC = { API_VERSION = "v6" PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, @@ -84,6 +85,7 @@ CATEGORY_TO_MODEL = { } HOME_COMPATIBLE_CATEGORIES = [ + FreeboxHomeCategory.ALARM, FreeboxHomeCategory.CAMERA, FreeboxHomeCategory.DWS, FreeboxHomeCategory.IOHOME, diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 63bc1d76d1a..5d1b6fab0c8 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -10,7 +10,7 @@ from .const import ( DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, - DATA_HOME_GET_VALUES, + DATA_HOME_PIR_GET_VALUES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, @@ -81,7 +81,7 @@ def mock_router(mock_device_registry_devices): # home devices instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.home.get_home_endpoint_value = AsyncMock( - return_value=DATA_HOME_GET_VALUES + return_value=DATA_HOME_PIR_GET_VALUES ) instance.close = AsyncMock() yield service_mock diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 788310bdbc0..0cd854b22bf 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -515,7 +515,7 @@ DATA_LAN_GET_HOSTS_LIST = [ # Home # PIR node id 26, endpoint id 6 -DATA_HOME_GET_VALUES = { +DATA_HOME_PIR_GET_VALUES = { "category": "", "ep_type": "signal", "id": 6, @@ -527,6 +527,15 @@ DATA_HOME_GET_VALUES = { "visibility": "normal", } +# Home +# ALARM node id 7, endpoint id 11 +DATA_HOME_ALARM_GET_VALUES = { + "refresh": 2000, + "value": "alarm2_armed", + "value_type": "string", +} + + # Home # ALL DATA_HOME_GET_NODES = [ @@ -2526,4 +2535,354 @@ DATA_HOME_GET_NODES = [ "inherit": "node::ios", }, }, + { + "adapter": 5, + "category": "alarm", + "group": {"label": ""}, + "id": 7, + "label": "Système d'alarme", + "name": "node_7", + "props": { + "Address": 3, + "Challenge": "447599f5cab8620122b913e55faf8e1d", + "FwVersion": 47396239, + "Gateway": 1, + "ItemId": "e515a55b04f32e6d", + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "ui": {...}, + "value": "", + "value_type": "string", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "refresh": 2000, + "ui": {...}, + "value": "0000", + "value_type": "string", + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "refresh": 2000, + "ui": {...}, + "value": 1, + "value_type": "int", + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "refresh": 2000, + "ui": {...}, + "value": 100, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "refresh": 2000, + "ui": {...}, + "value": 15, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "refresh": 2000, + "ui": {...}, + "value": 15, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "refresh": 2000, + "ui": {...}, + "value": 300, + "value_type": "int", + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": {...}, + "value": 85, + "value_type": "int", + }, + ], + "type": { + "abstract": False, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Trigger", + "name": "trigger", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "void", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 2, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 3, + "label": "Passer le délai", + "name": "skip", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 4, + "label": "Désactiver l'alarme", + "name": "off", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "value_type": "string", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 11, + "label": "État de l'alarme", + "name": "state", + "param_type": "void", + "value_type": "string", + "visibility": "internal", + }, + { + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "param_type": "void", + "value_type": "string", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 13, + "label": "Erreur", + "name": "error", + "param_type": "void", + "value_type": "string", + "visibility": "internal", + }, + { + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 20, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + ], + "generic": False, + "icon": "/resources/images/home/pictos/alarm_system.png", + "inherit": "node::domus", + "label": "Système d'alarme", + "name": "node::domus::freebox::secmod", + "params": {}, + "physical": True, + }, + }, ] diff --git a/tests/components/freebox/test_alarm_control_panel.py b/tests/components/freebox/test_alarm_control_panel.py new file mode 100644 index 00000000000..d24c747f2a3 --- /dev/null +++ b/tests/components/freebox/test_alarm_control_panel.py @@ -0,0 +1,123 @@ +"""Tests for the Freebox sensors.""" +from copy import deepcopy +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL, + AlarmControlPanelEntityFeature, +) +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.const import ( + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.state import async_reproduce_state + +from .common import setup_platform +from .const import DATA_HOME_ALARM_GET_VALUES + +from tests.common import async_fire_time_changed, async_mock_service + + +async def test_panel( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test home binary sensors.""" + await setup_platform(hass, ALARM_CONTROL_PANEL) + + # Initial state + assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "unknown" + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ + "supported_features" + ] + == AlarmControlPanelEntityFeature.ARM_AWAY + ) + + # Now simulate a changed status + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUES) + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state == "armed_night" + ) + # Fake that the entity is triggered. + hass.states.async_set("alarm_control_panel.systeme_d_alarme", STATE_ALARM_DISARMED) + assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "disarmed" + + +async def test_reproducing_states( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test reproducing Alarm control panel states.""" + hass.states.async_set( + "alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_custom_bypass", + STATE_ALARM_ARMED_CUSTOM_BYPASS, + {}, + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED, {} + ) + + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_AWAY) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_CUSTOM_BYPASS) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_HOME) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_NIGHT) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_VACATION) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_DISARM) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_TRIGGER) + + # These calls should do nothing as entities already in desired state + await async_reproduce_state( + hass, + [ + State("alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY), + State( + "alarm_control_panel.entity_armed_custom_bypass", + STATE_ALARM_ARMED_CUSTOM_BYPASS, + ), + State("alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME), + State("alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT), + State( + "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION + ), + State("alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED), + State("alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED), + ], + ) diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index b37d6a3c72c..2fd308ea667 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_GET_VALUES, DATA_STORAGE_GET_RAIDS +from .const import DATA_HOME_PIR_GET_VALUES, DATA_STORAGE_GET_RAIDS from tests.common import async_fire_time_changed @@ -73,7 +73,7 @@ async def test_home( assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off" # Now simulate a changed status - data_home_get_values_changed = deepcopy(DATA_HOME_GET_VALUES) + data_home_get_values_changed = deepcopy(DATA_HOME_PIR_GET_VALUES) data_home_get_values_changed["value"] = True router().home.get_home_endpoint_value.return_value = data_home_get_values_changed From e7a867f6301fd8e68989884d7296ecffd20cf897 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 26 Oct 2023 17:26:27 +0200 Subject: [PATCH 021/982] Update frontend to 20231026.0 (#102857) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 064777b4921..31f4dc14559 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231025.1"] + "requirements": ["home-assistant-frontend==20231026.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3aff4601d45..e99afb6330b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 89abaca7f30..6fbc3f9cc7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d55f675f53..72c74342a89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From c741b8cbd14933b461e668abd2111b5f281f46ee Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Oct 2023 17:31:53 +0200 Subject: [PATCH 022/982] Bump aiowithings to 1.0.2 (#102852) --- 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 a1df31ceecc..d43ae7da50c 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==1.0.1"] + "requirements": ["aiowithings==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6fbc3f9cc7a..67d95d5e188 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -387,7 +387,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.1 +aiowithings==1.0.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72c74342a89..cb3b6482e3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.1 +aiowithings==1.0.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 From af9cae289f15910252462b533a8d9e31faf6d9dd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 26 Oct 2023 09:43:10 -0700 Subject: [PATCH 023/982] Change todo move API to reference previous uid (#102795) --- homeassistant/components/local_todo/todo.py | 28 +++++--- .../components/shopping_list/__init__.py | 24 ++++--- .../components/shopping_list/todo.py | 6 +- homeassistant/components/todo/__init__.py | 18 +++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- tests/components/local_todo/test_todo.py | 72 ++++++++++++++----- tests/components/shopping_list/test_todo.py | 35 +++++---- tests/components/todo/test_init.py | 15 ++-- 8 files changed, 131 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 14d14316faf..7e23d01ee46 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -139,20 +139,28 @@ class LocalTodoListEntity(TodoListEntity): await self._async_save() await self.async_update_ha_state(force_refresh=True) - async def async_move_todo_item(self, uid: str, pos: int) -> None: + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: """Re-order an item to the To-do list.""" + if uid == previous_uid: + return todos = self._calendar.todos - found_item: Todo | None = None - for idx, itm in enumerate(todos): - if itm.uid == uid: - found_item = itm - todos.pop(idx) - break - if found_item is None: + item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)} + if uid not in item_idx: raise HomeAssistantError( - f"Item '{uid}' not found in todo list {self.entity_id}" + "Item '{uid}' not found in todo list {self.entity_id}" ) - todos.insert(pos, found_item) + if previous_uid and previous_uid not in item_idx: + raise HomeAssistantError( + "Item '{previous_uid}' not found in todo list {self.entity_id}" + ) + dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0 + src_idx = item_idx[uid] + src_item = todos.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + todos.insert(dst_idx, src_item) await self._async_save() await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index f2de59b10af..e2f04b5d880 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -322,17 +322,23 @@ class ShoppingData: context=context, ) - async def async_move_item(self, uid: str, pos: int) -> None: + async def async_move_item(self, uid: str, previous: str | None = None) -> None: """Re-order a shopping list item.""" - found_item: dict[str, Any] | None = None - for idx, itm in enumerate(self.items): - if cast(str, itm["id"]) == uid: - found_item = itm - self.items.pop(idx) - break - if not found_item: + if uid == previous: + return + item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} + if uid not in item_idx: raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") - self.items.insert(pos, found_item) + if previous and previous not in item_idx: + raise NoMatchingShoppingListItem( + f"Item '{previous}' not found in shopping list" + ) + dst_idx = item_idx[previous] + 1 if previous else 0 + src_idx = item_idx[uid] + src_item = self.items.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + self.items.insert(dst_idx, src_item) await self.hass.async_add_executor_job(self.save) self._async_notify() self.hass.bus.async_fire( diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 53c9e6b6d74..d89f376d662 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -71,11 +71,13 @@ class ShoppingTodoListEntity(TodoListEntity): """Add an item to the To-do list.""" await self._data.async_remove_items(set(uids)) - async def async_move_todo_item(self, uid: str, pos: int) -> None: + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: """Re-order an item to the To-do list.""" try: - await self._data.async_move_item(uid, pos) + await self._data.async_move_item(uid, previous_uid) except NoMatchingShoppingListItem as err: raise HomeAssistantError( f"Shopping list item '{uid}' could not be re-ordered" diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index a6660b0231a..12eac858f75 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -152,8 +152,15 @@ class TodoListEntity(Entity): """Delete an item in the To-do list.""" raise NotImplementedError() - async def async_move_todo_item(self, uid: str, pos: int) -> None: - """Move an item in the To-do list.""" + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Move an item in the To-do list. + + The To-do item with the specified `uid` should be moved to the position + in the list after the specified by `previous_uid` or `None` for the first + position in the To-do list. + """ raise NotImplementedError() @@ -190,7 +197,7 @@ async def websocket_handle_todo_item_list( vol.Required("type"): "todo/item/move", vol.Required("entity_id"): cv.entity_id, vol.Required("uid"): cv.string, - vol.Optional("pos", default=0): cv.positive_int, + vol.Optional("previous_uid"): cv.string, } ) @websocket_api.async_response @@ -215,9 +222,10 @@ async def websocket_handle_todo_item_move( ) ) return - try: - await entity.async_move_todo_item(uid=msg["uid"], pos=msg["pos"]) + await entity.async_move_todo_item( + uid=msg["uid"], previous_uid=msg.get("previous_uid") + ) except HomeAssistantError as ex: connection.send_error(msg["id"], "failed", str(ex)) else: diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 845b70b72ba..f43dd9b6672 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2469,7 +2469,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="async_move_todo_item", arg_types={ 1: "str", - 2: "int", + 2: "str | None", }, return_type="None", ), diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 6d06649a6ba..8a7e38c9773 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -59,7 +59,7 @@ async def ws_move_item( ) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" - async def move(uid: str, pos: int) -> None: + async def move(uid: str, previous_uid: str | None) -> None: # Fetch items using To-do platform client = await hass_ws_client() id = ws_req_id() @@ -68,8 +68,9 @@ async def ws_move_item( "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": uid, - "pos": pos, } + if previous_uid is not None: + data["previous_uid"] = previous_uid await client.send_json(data) resp = await client.receive_json() assert resp.get("id") == id @@ -237,30 +238,29 @@ async def test_update_item( @pytest.mark.parametrize( - ("src_idx", "pos", "expected_items"), + ("src_idx", "dst_idx", "expected_items"), [ # Move any item to the front of the list - (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 0, ["item 2", "item 1", "item 3", "item 4"]), - (2, 0, ["item 3", "item 1", "item 2", "item 4"]), - (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), # Move items right (0, 1, ["item 2", "item 1", "item 3", "item 4"]), (0, 2, ["item 2", "item 3", "item 1", "item 4"]), (0, 3, ["item 2", "item 3", "item 4", "item 1"]), (1, 2, ["item 1", "item 3", "item 2", "item 4"]), (1, 3, ["item 1", "item 3", "item 4", "item 2"]), - (1, 4, ["item 1", "item 3", "item 4", "item 2"]), - (1, 5, ["item 1", "item 3", "item 4", "item 2"]), # Move items left - (2, 1, ["item 1", "item 3", "item 2", "item 4"]), - (3, 1, ["item 1", "item 4", "item 2", "item 3"]), - (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), # No-ops - (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), (3, 3, ["item 1", "item 2", "item 3", "item 4"]), - (3, 4, ["item 1", "item 2", "item 3", "item 4"]), ], ) async def test_move_item( @@ -269,7 +269,7 @@ async def test_move_item( ws_get_items: Callable[[], Awaitable[dict[str, str]]], ws_move_item: Callable[[str, str | None], Awaitable[None]], src_idx: int, - pos: int, + dst_idx: int | None, expected_items: list[str], ) -> None: """Test moving a todo item within the list.""" @@ -289,7 +289,10 @@ async def test_move_item( assert summaries == ["item 1", "item 2", "item 3", "item 4"] # Prepare items for moving - await ws_move_item(uids[src_idx], pos) + previous_uid = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + await ws_move_item(uids[src_idx], previous_uid) items = await ws_get_items() assert len(items) == 4 @@ -311,7 +314,42 @@ async def test_move_item_unknown( "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": "unknown", - "pos": 0, + "previous_uid": "item-2", + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "not found in todo list" in resp["error"]["message"] + + +async def test_move_item_previous_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test moving a todo item that does not exist.""" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "item 1"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + items = await ws_get_items() + assert len(items) == 1 + + # Prepare items for moving + client = await hass_ws_client() + data = { + "id": 1, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": items[0]["uid"], + "previous_uid": "unknown", } await client.send_json(data) resp = await client.receive_json() diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 15f1e50bdb9..ab28c6cbe6d 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -57,10 +57,10 @@ async def ws_get_items( async def ws_move_item( hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int], -) -> Callable[[str, int | None], Awaitable[None]]: +) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" - async def move(uid: str, pos: int | None) -> dict[str, Any]: + async def move(uid: str, previous_uid: str | None) -> dict[str, Any]: # Fetch items using To-do platform client = await hass_ws_client() id = ws_req_id() @@ -70,8 +70,8 @@ async def ws_move_item( "entity_id": TEST_ENTITY, "uid": uid, } - if pos is not None: - data["pos"] = pos + if previous_uid is not None: + data["previous_uid"] = previous_uid await client.send_json(data) resp = await client.receive_json() assert resp.get("id") == id @@ -406,10 +406,10 @@ async def test_update_invalid_item( ("src_idx", "dst_idx", "expected_items"), [ # Move any item to the front of the list - (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 0, ["item 2", "item 1", "item 3", "item 4"]), - (2, 0, ["item 3", "item 1", "item 2", "item 4"]), - (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), # Move items right (0, 1, ["item 2", "item 1", "item 3", "item 4"]), (0, 2, ["item 2", "item 3", "item 1", "item 4"]), @@ -417,15 +417,15 @@ async def test_update_invalid_item( (1, 2, ["item 1", "item 3", "item 2", "item 4"]), (1, 3, ["item 1", "item 3", "item 4", "item 2"]), # Move items left - (2, 1, ["item 1", "item 3", "item 2", "item 4"]), - (3, 1, ["item 1", "item 4", "item 2", "item 3"]), - (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), # No-ops (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), (3, 3, ["item 1", "item 2", "item 3", "item 4"]), - (3, 4, ["item 1", "item 2", "item 3", "item 4"]), ], ) async def test_move_item( @@ -433,7 +433,7 @@ async def test_move_item( sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], - ws_move_item: Callable[[str, int | None], Awaitable[dict[str, Any]]], + ws_move_item: Callable[[str, str | None], Awaitable[dict[str, Any]]], src_idx: int, dst_idx: int | None, expected_items: list[str], @@ -457,7 +457,12 @@ async def test_move_item( summaries = [item["summary"] for item in items] assert summaries == ["item 1", "item 2", "item 3", "item 4"] - resp = await ws_move_item(uids[src_idx], dst_idx) + # Prepare items for moving + previous_uid: str | None = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + + resp = await ws_move_item(uids[src_idx], previous_uid) assert resp.get("success") items = await ws_get_items() diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 833a4ea266b..f4d671ad352 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -571,7 +571,7 @@ async def test_move_todo_item_service_by_id( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() @@ -581,7 +581,7 @@ async def test_move_todo_item_service_by_id( args = test_entity.async_move_todo_item.call_args assert args assert args.kwargs.get("uid") == "item-1" - assert args.kwargs.get("pos") == 1 + assert args.kwargs.get("previous_uid") == "item-2" async def test_move_todo_item_service_raises( @@ -601,7 +601,7 @@ async def test_move_todo_item_service_raises( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() @@ -620,15 +620,10 @@ async def test_move_todo_item_service_raises( ), ({"entity_id": "todo.entity1"}, "invalid_format", "required key not provided"), ( - {"entity_id": "todo.entity1", "pos": "2"}, + {"entity_id": "todo.entity1", "previous_uid": "item-2"}, "invalid_format", "required key not provided", ), - ( - {"entity_id": "todo.entity1", "uid": "item-1", "pos": "-2"}, - "invalid_format", - "value must be at least 0", - ), ], ) async def test_move_todo_item_service_invalid_input( @@ -722,7 +717,7 @@ async def test_move_item_unsupported( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() From ae9106effd767e634ce6ebd7af83f5c9409e2dd0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Oct 2023 21:34:59 +0200 Subject: [PATCH 024/982] Improve exception handling for Vodafone Station (#102761) * improve exception handling for Vodafone Station * address review comment * apply review comment * better except handling (bump library) * cleanup --- .../vodafone_station/coordinator.py | 22 +++++++++++-------- .../components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 38fc80ac3af..a2cddcf9a65 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -95,15 +95,19 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Update router data.""" _LOGGER.debug("Polling Vodafone Station host: %s", self._host) try: - logged = await self.api.login() - except exceptions.CannotConnect as err: - _LOGGER.warning("Connection error for %s", self._host) - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err - except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err - - if not logged: - raise ConfigEntryAuthFailed + try: + await self.api.login() + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err + except ( + exceptions.CannotConnect, + exceptions.AlreadyLogged, + exceptions.GenericLoginError, + ) as err: + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except (ConfigEntryAuthFailed, UpdateFailed): + await self.api.close() + raise utc_point_in_time = dt_util.utcnow() data_devices = { diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 628c25b987e..2a1814c83d0 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.1"] + "requirements": ["aiovodafone==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67d95d5e188..9e0b30ab7f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ aiounifi==64 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb3b6482e3c..d3c0abf88ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aiounifi==64 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 From 3b2a849f7716f4f517ea15d7deff591ac330cadc Mon Sep 17 00:00:00 2001 From: mletenay Date: Thu, 26 Oct 2023 22:20:29 +0200 Subject: [PATCH 025/982] Update goodwe library to 0.2.32 (#102868) --- homeassistant/components/goodwe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index f40d2253614..03575f9f4e2 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.31"] + "requirements": ["goodwe==0.2.32"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9e0b30ab7f8..0a2d38fb76b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -894,7 +894,7 @@ glances-api==0.4.3 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.2.31 +goodwe==0.2.32 # homeassistant.components.google_mail # homeassistant.components.google_tasks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3c0abf88ee..0cc1096a60a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -713,7 +713,7 @@ glances-api==0.4.3 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.2.31 +goodwe==0.2.32 # homeassistant.components.google_mail # homeassistant.components.google_tasks From 6b5f2a1349ad9eedbf3ae33d7959f6ddee6f0c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 26 Oct 2023 23:53:35 +0100 Subject: [PATCH 026/982] Cleanup exception logging in Idasen Desk (#102617) * Cleaup exception logging in Idasen Desk * Apply suggestions from code review Co-authored-by: J. Nick Koston * Re-add trace * Remove uneeded exc_info --------- Co-authored-by: J. Nick Koston --- homeassistant/components/idasen_desk/config_flow.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index caa8d866fc3..80282ce0271 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -65,14 +65,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): desk = Desk(None, monitor_height=False) try: await desk.connect(discovery_info.device, auto_reconnect=False) - except AuthFailedError as err: - _LOGGER.exception("AuthFailedError", exc_info=err) + except AuthFailedError: errors["base"] = "auth_failed" - except TimeoutError as err: - _LOGGER.exception("TimeoutError", exc_info=err) + except TimeoutError: errors["base"] = "cannot_connect" - except BleakError as err: - _LOGGER.exception("BleakError", exc_info=err) + except BleakError: + _LOGGER.exception("Unexpected Bluetooth error") errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error") From 8eaf38cd44a4c1e5a77c3555f54331307fe8b985 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 27 Oct 2023 10:51:45 +0200 Subject: [PATCH 027/982] Fix mqtt schema import not available for mqtt_room (#102866) --- homeassistant/components/mqtt/__init__.py | 1 + homeassistant/components/mqtt_room/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 1f8e5bbf2e7..ac229cb677f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -47,6 +47,7 @@ from .client import ( # noqa: F401 publish, subscribe, ) +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 from .config_integration import CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 4eb3a3f5171..cb0e840604e 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -47,7 +47,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } -).extend(mqtt.config.MQTT_RO_SCHEMA.schema) +).extend(mqtt.MQTT_RO_SCHEMA.schema) @lru_cache(maxsize=256) From e0885ef109f4ec101c604e8e8a94db2c8601d159 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 27 Oct 2023 11:30:37 +0200 Subject: [PATCH 028/982] Don't return resources in safe mode (#102865) --- .../components/lovelace/websocket.py | 3 +++ tests/components/lovelace/test_resources.py | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index 423ba3117ea..c9b7cb10386 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -60,6 +60,9 @@ async def websocket_lovelace_resources( """Send Lovelace UI resources over WebSocket configuration.""" resources = hass.data[DOMAIN]["resources"] + if hass.config.safe_mode: + connection.send_result(msg["id"], []) + if not resources.loaded: await resources.async_load() resources.loaded = True diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 1e2a121d6fb..f7830f03ed6 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -185,3 +185,26 @@ async def test_storage_resources_import_invalid( "resources" in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"] ) + + +async def test_storage_resources_safe_mode( + hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] +) -> None: + """Test defining resources in storage config.""" + + resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES] + hass_storage[resources.RESOURCE_STORAGE_KEY] = { + "key": resources.RESOURCE_STORAGE_KEY, + "version": 1, + "data": {"items": resource_config}, + } + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + hass.config.safe_mode = True + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] From 43915fbaf305f709ed4b29bf41c63d1617d17d67 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 27 Oct 2023 12:09:59 +0200 Subject: [PATCH 029/982] Add connections to PassiveBluetoothProcessorEntity (#102854) --- .../components/bluetooth/passive_update_processor.py | 5 ++++- .../bluetooth/test_passive_update_processor.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8138587b9b5..7dd39c14039 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast from homeassistant import config_entries from homeassistant.const import ( + ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_NAME, CONF_ENTITY_CATEGORY, @@ -16,7 +17,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import async_get_current_platform from homeassistant.helpers.event import async_track_time_interval @@ -644,6 +645,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce self._attr_unique_id = f"{address}-{key}" if ATTR_NAME not in self._attr_device_info: self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name + if device_id is None: + self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)} self._attr_name = processor.entity_names.get(entity_key) @property diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 9e3f954a0c5..8cc76e01d8c 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1208,6 +1208,7 @@ async def test_integration_with_entity_without_a_device( assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature" assert entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "name": "Generic", } assert entity_one.entity_key == PassiveBluetoothEntityKey( @@ -1396,6 +1397,7 @@ async def test_integration_multiple_entity_platforms( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1412,6 +1414,7 @@ async def test_integration_multiple_entity_platforms( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1556,6 +1559,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1572,6 +1576,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1636,6 +1641,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1652,6 +1658,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1730,6 +1737,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1746,6 +1754,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", From 294f565bad23ae072f98558b754d4fb167c2f6d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Oct 2023 12:25:27 +0200 Subject: [PATCH 030/982] Allow missing components in safe mode (#102888) --- homeassistant/helpers/check_config.py | 2 +- tests/helpers/test_check_config.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 3218c1e839b..4aa4e72b0bb 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -127,7 +127,7 @@ async def async_check_ha_config_file( # noqa: C901 try: integration = await async_get_integration_with_requirements(hass, domain) except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Integration error: {domain} - {ex}") continue except RequirementsNotFound as ex: diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 6af03136760..a3fd02686ac 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -125,6 +125,19 @@ async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: assert not res.errors +async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if component not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert not res.errors + + async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: """Test errors if component or platform not found.""" # Make sure they don't exist From a7183a0cbf0288c3b8f35e4808d9e8f182fb32a4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Oct 2023 13:26:26 +0200 Subject: [PATCH 031/982] Allow missing components in safe mode (#102891) --- homeassistant/helpers/check_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 4aa4e72b0bb..a5e68cb877d 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -216,7 +216,7 @@ async def async_check_ha_config_file( # noqa: C901 ) platform = p_integration.get_platform(domain) except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Platform error {domain}.{p_name} - {ex}") continue except ( From c77a3facf59c3f744647b4b810f0367d7f8f8f26 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 13:28:16 +0200 Subject: [PATCH 032/982] Some textual fixes for todo (#102895) --- homeassistant/components/todo/manifest.json | 2 +- homeassistant/components/todo/services.yaml | 4 +-- homeassistant/components/todo/strings.json | 34 ++++++++++----------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/todo/manifest.json b/homeassistant/components/todo/manifest.json index 2edf3309e32..8efc93ad4e7 100644 --- a/homeassistant/components/todo/manifest.json +++ b/homeassistant/components/todo/manifest.json @@ -1,6 +1,6 @@ { "domain": "todo", - "name": "To-do", + "name": "To-do list", "codeowners": ["@home-assistant/core"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/todo", diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index cf5f3da2b3a..c31a7e88808 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -7,7 +7,7 @@ create_item: fields: summary: required: true - example: "Submit Income Tax Return" + example: "Submit income tax return" selector: text: status: @@ -29,7 +29,7 @@ update_item: selector: text: summary: - example: "Submit Income Tax Return" + example: "Submit income tax return" selector: text: status: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 4a5a33e94e5..623c46375f0 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -1,5 +1,5 @@ { - "title": "To-do List", + "title": "To-do list", "entity_component": { "_": { "name": "[%key:component::todo::title%]" @@ -7,48 +7,48 @@ }, "services": { "create_item": { - "name": "Create To-do List Item", - "description": "Add a new To-do List Item.", + "name": "Create to-do list item", + "description": "Add a new to-do list item.", "fields": { "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." }, "status": { "name": "Status", - "description": "A status or confirmation of the To-do item." + "description": "A status or confirmation of the to-do item." } } }, "update_item": { - "name": "Update To-do List Item", - "description": "Update an existing To-do List Item based on either its Unique Id or Summary.", + "name": "Update to-do list item", + "description": "Update an existing to-do list item based on either its unique ID or summary.", "fields": { "uid": { - "name": "To-do Item Unique Id", - "description": "Unique Identifier for the To-do List Item." + "name": "To-do item unique ID", + "description": "Unique identifier for the to-do list item." }, "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." }, "status": { "name": "Status", - "description": "A status or confirmation of the To-do item." + "description": "A status or confirmation of the to-do item." } } }, "delete_item": { - "name": "Delete a To-do List Item", - "description": "Delete an existing To-do List Item either by its Unique Id or Summary.", + "name": "Delete a to-do list item", + "description": "Delete an existing to-do list item either by its unique ID or summary.", "fields": { "uid": { - "name": "To-do Item Unique Ids", - "description": "Unique Identifiers for the To-do List Items." + "name": "To-do item unique IDs", + "description": "Unique identifiers for the to-do list items." }, "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." } } } @@ -56,7 +56,7 @@ "selector": { "status": { "options": { - "needs_action": "Needs Action", + "needs_action": "Not completed", "completed": "Completed" } } From 3f56ca49c64e15e6b4aba6c9615515eae8152e8b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 27 Oct 2023 13:55:22 +0200 Subject: [PATCH 033/982] Add redirect from shopping list to todo (#102894) --- homeassistant/components/frontend/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8201cbc5b7a..2ec991750f0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -388,6 +388,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Can be removed in 2023 hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") + # Shopping list panel was replaced by todo panel in 2023.11 + hass.http.register_redirect("/shopping-list", "/todo") + hass.http.app.router.register_resource(IndexView(repo_path, hass)) async_register_built_in_panel(hass, "profile") From a516f32bbdd54c1a76b3ed789d06f64d46b808cc Mon Sep 17 00:00:00 2001 From: Magnus Larsson Date: Fri, 27 Oct 2023 16:49:01 +0200 Subject: [PATCH 034/982] Use new API for Vasttrafik (#102570) --- .../components/vasttrafik/manifest.json | 2 +- homeassistant/components/vasttrafik/sensor.py | 44 +++++++++++++------ requirements_all.txt | 2 +- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json index aa1907a8a23..336d06e182c 100644 --- a/homeassistant/components/vasttrafik/manifest.json +++ b/homeassistant/components/vasttrafik/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/vasttrafik", "iot_class": "cloud_polling", "loggers": ["vasttrafik"], - "requirements": ["vtjp==0.1.14"] + "requirements": ["vtjp==0.2.1"] } diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 711f66ea033..6a083232079 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -1,7 +1,7 @@ """Support for Västtrafik public transport.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging import vasttrafik @@ -22,6 +22,9 @@ ATTR_ACCESSIBILITY = "accessibility" ATTR_DIRECTION = "direction" ATTR_LINE = "line" ATTR_TRACK = "track" +ATTR_FROM = "from" +ATTR_TO = "to" +ATTR_DELAY = "delay" CONF_DEPARTURES = "departures" CONF_FROM = "from" @@ -32,7 +35,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 - MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -101,7 +103,7 @@ class VasttrafikDepartureSensor(SensorEntity): if location.isdecimal(): station_info = {"station_name": location, "station_id": location} else: - station_id = self._planner.location_name(location)[0]["id"] + station_id = self._planner.location_name(location)[0]["gid"] station_info = {"station_name": location, "station_id": station_id} return station_info @@ -143,20 +145,36 @@ class VasttrafikDepartureSensor(SensorEntity): self._attributes = {} else: for departure in self._departureboard: - line = departure.get("sname") - if "cancelled" in departure: + service_journey = departure.get("serviceJourney", {}) + line = service_journey.get("line", {}) + + if departure.get("isCancelled"): continue - if not self._lines or line in self._lines: - if "rtTime" in departure: - self._state = departure["rtTime"] + if not self._lines or line.get("shortName") in self._lines: + if "estimatedOtherwisePlannedTime" in departure: + try: + self._state = datetime.fromisoformat( + departure["estimatedOtherwisePlannedTime"] + ).strftime("%H:%M") + except ValueError: + self._state = departure["estimatedOtherwisePlannedTime"] else: - self._state = departure["time"] + self._state = None + + stop_point = departure.get("stopPoint", {}) params = { - ATTR_ACCESSIBILITY: departure.get("accessibility"), - ATTR_DIRECTION: departure.get("direction"), - ATTR_LINE: departure.get("sname"), - ATTR_TRACK: departure.get("track"), + ATTR_ACCESSIBILITY: "wheelChair" + if line.get("isWheelchairAccessible") + else None, + ATTR_DIRECTION: service_journey.get("direction"), + ATTR_LINE: line.get("shortName"), + ATTR_TRACK: stop_point.get("platform"), + ATTR_FROM: stop_point.get("name"), + ATTR_TO: self._heading["station_name"] + if self._heading + else "ANY", + ATTR_DELAY: self._delay.seconds // 60 % 60, } self._attributes = {k: v for k, v in params.items() if v} diff --git a/requirements_all.txt b/requirements_all.txt index 0a2d38fb76b..8886b987ad9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2682,7 +2682,7 @@ volvooncall==0.10.3 vsure==2.6.6 # homeassistant.components.vasttrafik -vtjp==0.1.14 +vtjp==0.2.1 # homeassistant.components.vulcan vulcan-api==2.3.0 From ca5bcb9ab1f7159ff8d9df99ba5ef763c98f1d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 27 Oct 2023 17:36:28 +0200 Subject: [PATCH 035/982] Update aioairzone-cloud to v0.3.1 (#102899) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/util.py | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index a3c0f5e7dc0..eb959342122 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.0"] + "requirements": ["aioairzone-cloud==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8886b987ad9..9aebf59ad0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.0 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0cc1096a60a..c37835c3f73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.0 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 412f0df1337..76349d06481 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -101,6 +101,7 @@ GET_INSTALLATION_MOCK = { API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone1", API_NAME: "Salon", API_TYPE: API_AZ_ZONE, @@ -111,6 +112,7 @@ GET_INSTALLATION_MOCK = { API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone2", API_NAME: "Dormitorio", API_TYPE: API_AZ_ZONE, From c3da075554ff855972de23a4c0ced8a7634fab7e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 27 Oct 2023 17:50:33 +0200 Subject: [PATCH 036/982] Use present wording in version bump script (#102897) --- script/version_bump.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/version_bump.py b/script/version_bump.py index 5e383ab7d4b..3a6c0fa7540 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -177,7 +177,7 @@ def main(): if not arguments.commit: return - subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"], check=True) + subprocess.run(["git", "commit", "-nam", f"Bump version to {bumped}"], check=True) def test_bump_version(): From c7d7ce457c0f2cc627d2ff1b65cd423decb87835 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 27 Oct 2023 18:15:16 +0200 Subject: [PATCH 037/982] Bump velbusaio to 2023.10.2 (#102919) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 229ee8458c6..3c773e39e33 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.1"], + "requirements": ["velbus-aio==2023.10.2"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 9aebf59ad0e..de94a0d087d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2661,7 +2661,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c37835c3f73..2f904fd52f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1979,7 +1979,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 From 1c1ff560219bf61f536993cca2b224ef0c40ef95 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 27 Oct 2023 19:17:15 +0200 Subject: [PATCH 038/982] Update frontend to 20231027.0 (#102913) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 31f4dc14559..a47ef38264e 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231026.0"] + "requirements": ["home-assistant-frontend==20231027.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e99afb6330b..5d68cead747 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index de94a0d087d..801fc99466b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f904fd52f9..9851e1a94dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 9c6884a52608d6e8fc3e63d298fe924c8260cead Mon Sep 17 00:00:00 2001 From: myztillx <33730898+myztillx@users.noreply.github.com> Date: Fri, 27 Oct 2023 15:46:59 -0400 Subject: [PATCH 039/982] Bump python-ecobee-api to 0.2.17 (#102900) --- homeassistant/components/ecobee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 71f5e04f75a..ffb7fe8adfe 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "cloud_polling", "loggers": ["pyecobee"], - "requirements": ["python-ecobee-api==0.2.14"], + "requirements": ["python-ecobee-api==0.2.17"], "zeroconf": [ { "type": "_ecobee._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 801fc99466b..6635c4b27ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2102,7 +2102,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.2.14 +python-ecobee-api==0.2.17 # homeassistant.components.eq3btsmart # python-eq3bt==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9851e1a94dd..876233d0ab6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1582,7 +1582,7 @@ python-awair==0.2.4 python-bsblan==0.5.16 # homeassistant.components.ecobee -python-ecobee-api==0.2.14 +python-ecobee-api==0.2.17 # homeassistant.components.fully_kiosk python-fullykiosk==0.0.12 From 4fa551612ed55530e659f2931f9b2136668f4ea8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 23:26:03 +0200 Subject: [PATCH 040/982] Move HomeWizard Energy identify button to config entity category (#102932) --- homeassistant/components/homewizard/button.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 96fe1b157f8..19ffb1d6042 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -24,7 +24,7 @@ async def async_setup_entry( class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): """Representation of a identify button.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.IDENTIFY def __init__( From 100c3079ae77fbff1dd0d6e15b151d7276052b92 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 23:26:28 +0200 Subject: [PATCH 041/982] Hide mac address from HomeWizard Energy config entry/discovery titles (#102931) --- homeassistant/components/homewizard/config_flow.py | 14 +++++++++----- tests/components/homewizard/test_config_flow.py | 14 +++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 82c808a0f13..b24b49da965 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -62,7 +62,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( - title=f"{device_info.product_name} ({device_info.serial})", + title=f"{device_info.product_name}", data=user_input, ) @@ -121,14 +121,18 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors = {"base": ex.error_code} else: return self.async_create_entry( - title=f"{self.discovery.product_name} ({self.discovery.serial})", + title=self.discovery.product_name, data={CONF_IP_ADDRESS: self.discovery.ip}, ) self._set_confirm_only() - self.context["title_placeholders"] = { - "name": f"{self.discovery.product_name} ({self.discovery.serial})" - } + + # We won't be adding mac/serial to the title for devices + # that users generally don't have multiple of. + name = self.discovery.product_name + if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]: + name = f"{name} ({self.discovery.serial})" + self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7c6fb0bdb0d..770496b5612 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -43,7 +43,7 @@ async def test_manual_flow_works( ) assert result["type"] == "create_entry" - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -68,8 +68,8 @@ async def test_discovery_flow_works( properties={ "api_enabled": "1", "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", + "product_name": "Energy Socket", + "product_type": "HWE-SKT", "serial": "aabbccddeeff", }, ) @@ -109,11 +109,11 @@ async def test_discovery_flow_works( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "Energy Socket" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result["result"].unique_id == "HWE-SKT_aabbccddeeff" async def test_discovery_flow_during_onboarding( @@ -149,7 +149,7 @@ async def test_discovery_flow_during_onboarding( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] @@ -214,7 +214,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] From 923d2d0d811367bec10f4feecf2198aa328bcdcb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 23:26:41 +0200 Subject: [PATCH 042/982] Small base entity cleanup for HomeWizard Energy entities (#102933) --- homeassistant/components/homewizard/entity.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 51dbe9fcad3..61bf20dbbc4 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -18,17 +18,13 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): """Initialize the HomeWizard entity.""" super().__init__(coordinator=coordinator) self._attr_device_info = DeviceInfo( - name=coordinator.entry.title, manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, model=coordinator.data.device.product_type, ) - if coordinator.data.device.serial is not None: + if (serial_number := coordinator.data.device.serial) is not None: self._attr_device_info[ATTR_CONNECTIONS] = { - (CONNECTION_NETWORK_MAC, coordinator.data.device.serial) - } - - self._attr_device_info[ATTR_IDENTIFIERS] = { - (DOMAIN, coordinator.data.device.serial) + (CONNECTION_NETWORK_MAC, serial_number) } + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)} From fd1c1dba7cb37dffb0537594797c1fee9d6bcc1f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 23:27:02 +0200 Subject: [PATCH 043/982] Handle/extend number entity availability property in HomeWizard Energy (#102934) --- homeassistant/components/homewizard/number.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index d51d180edb1..07f6bb9b55f 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -47,13 +47,17 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) await self.coordinator.async_refresh() + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.state is not None + @property def native_value(self) -> float | None: """Return the current value.""" if ( - self.coordinator.data.state is None - or self.coordinator.data.state.brightness is None + not self.coordinator.data.state + or (brightness := self.coordinator.data.state.brightness) is None ): return None - brightness: float = self.coordinator.data.state.brightness return round(brightness * (100 / 255)) From 8e112c04fb5454be64efe9de12b2ceb34f00cc8a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 23:27:17 +0200 Subject: [PATCH 044/982] Improve diagnostic handling in HomeWizard Energy (#102935) --- .../components/homewizard/diagnostics.py | 33 +++++---- .../snapshots/test_diagnostics.ambr | 71 +++++++++++++++++++ .../components/homewizard/test_diagnostics.py | 70 ++---------------- 3 files changed, 97 insertions(+), 77 deletions(-) create mode 100644 tests/components/homewizard/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index a8f89b67ce9..b8103f7a4cb 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -28,18 +28,23 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - meter_data = { - "device": asdict(coordinator.data.device), - "data": asdict(coordinator.data.data), - "state": asdict(coordinator.data.state) - if coordinator.data.state is not None - else None, - "system": asdict(coordinator.data.system) - if coordinator.data.system is not None - else None, - } + state: dict[str, Any] | None = None + if coordinator.data.state: + state = asdict(coordinator.data.state) - return { - "entry": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(meter_data, TO_REDACT), - } + system: dict[str, Any] | None = None + if coordinator.data.system: + system = asdict(coordinator.data.system) + + return async_redact_data( + { + "entry": async_redact_data(entry.data, TO_REDACT), + "data": { + "device": asdict(coordinator.data.device), + "data": asdict(coordinator.data.data), + "state": state, + "system": system, + }, + }, + TO_REDACT, + ) diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5e1025a8d31 --- /dev/null +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': -4, + 'active_current_l2_a': 2, + 'active_current_l3_a': 0, + 'active_frequency_hz': 50, + 'active_liter_lpm': 12.345, + 'active_power_average_w': 123.0, + 'active_power_l1_w': -123, + 'active_power_l2_w': 456, + 'active_power_l3_w': 123.456, + 'active_power_w': -123, + 'active_tariff': 2, + 'active_voltage_l1_v': 230.111, + 'active_voltage_l2_v': 230.222, + 'active_voltage_l3_v': 230.333, + 'any_power_fail_count': 4, + 'external_devices': None, + 'gas_timestamp': '2021-03-14T11:22:33', + 'gas_unique_id': '**REDACTED**', + 'long_power_fail_count': 5, + 'meter_model': 'ISKRA 2M550T-101', + 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', + 'monthly_power_peak_w': 1111.0, + 'smr_version': 50, + 'total_gas_m3': 1122.333, + 'total_liter_m3': 1234.567, + 'total_power_export_kwh': 13086.777, + 'total_power_export_t1_kwh': 4321.333, + 'total_power_export_t2_kwh': 8765.444, + 'total_power_export_t3_kwh': None, + 'total_power_export_t4_kwh': None, + 'total_power_import_kwh': 13779.338, + 'total_power_import_t1_kwh': 10830.511, + 'total_power_import_t2_kwh': 2948.827, + 'total_power_import_t3_kwh': None, + 'total_power_import_t4_kwh': None, + 'unique_meter_id': '**REDACTED**', + 'voltage_sag_l1_count': 1, + 'voltage_sag_l2_count': 2, + 'voltage_sag_l3_count': 3, + 'voltage_swell_l1_count': 4, + 'voltage_swell_l2_count': 5, + 'voltage_swell_l3_count': 6, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.11', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'state': dict({ + 'brightness': 255, + 'power_on': True, + 'switch_lock': False, + }), + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 64e8b0c6dfd..9e9797439b3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -1,6 +1,7 @@ """Tests for diagnostics data.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,67 +13,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": {"ip_address": REDACTED}, - "data": { - "device": { - "product_name": "P1 Meter", - "product_type": "HWE-SKT", - "serial": REDACTED, - "api_version": "v1", - "firmware_version": "2.11", - }, - "data": { - "wifi_ssid": REDACTED, - "wifi_strength": 100, - "smr_version": 50, - "meter_model": "ISKRA 2M550T-101", - "unique_meter_id": REDACTED, - "active_tariff": 2, - "total_power_import_kwh": 13779.338, - "total_power_import_t1_kwh": 10830.511, - "total_power_import_t2_kwh": 2948.827, - "total_power_import_t3_kwh": None, - "total_power_import_t4_kwh": None, - "total_power_export_kwh": 13086.777, - "total_power_export_t1_kwh": 4321.333, - "total_power_export_t2_kwh": 8765.444, - "total_power_export_t3_kwh": None, - "total_power_export_t4_kwh": None, - "active_power_w": -123, - "active_power_l1_w": -123, - "active_power_l2_w": 456, - "active_power_l3_w": 123.456, - "active_voltage_l1_v": 230.111, - "active_voltage_l2_v": 230.222, - "active_voltage_l3_v": 230.333, - "active_current_l1_a": -4, - "active_current_l2_a": 2, - "active_current_l3_a": 0, - "active_frequency_hz": 50, - "voltage_sag_l1_count": 1, - "voltage_sag_l2_count": 2, - "voltage_sag_l3_count": 3, - "voltage_swell_l1_count": 4, - "voltage_swell_l2_count": 5, - "voltage_swell_l3_count": 6, - "any_power_fail_count": 4, - "long_power_fail_count": 5, - "active_power_average_w": 123.0, - "monthly_power_peak_w": 1111.0, - "monthly_power_peak_timestamp": "2023-01-01T08:00:10", - "total_gas_m3": 1122.333, - "gas_timestamp": "2021-03-14T11:22:33", - "gas_unique_id": REDACTED, - "active_liter_lpm": 12.345, - "total_liter_m3": 1234.567, - "external_devices": None, - }, - "state": {"power_on": True, "switch_lock": False, "brightness": 255}, - "system": {"cloud_enabled": True}, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 2601c6789d7752e6a2f2f42697aa3472b95c06cb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 28 Oct 2023 13:56:45 +0200 Subject: [PATCH 045/982] Add entity translations to Airzone (#99054) --- .../components/airzone/binary_sensor.py | 10 ++------ homeassistant/components/airzone/climate.py | 3 +-- homeassistant/components/airzone/entity.py | 6 +++-- homeassistant/components/airzone/select.py | 7 +----- homeassistant/components/airzone/sensor.py | 9 +------ homeassistant/components/airzone/strings.json | 24 +++++++++++++++++++ .../components/airzone/water_heater.py | 3 +-- .../airzone/snapshots/test_diagnostics.ambr | 3 +++ .../components/airzone/test_binary_sensor.py | 14 +++++------ tests/components/airzone/test_sensor.py | 2 +- tests/components/airzone/util.py | 3 +++ 11 files changed, 48 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index a472a4991c6..cee0bb19691 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -9,7 +9,6 @@ from aioairzone.const import ( AZD_BATTERY_LOW, AZD_ERRORS, AZD_FLOOR_DEMAND, - AZD_NAME, AZD_PROBLEMS, AZD_SYSTEMS, AZD_ZONES, @@ -45,7 +44,6 @@ SYSTEM_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, .. device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, key=AZD_PROBLEMS, - name="Problem", ), ) @@ -53,17 +51,16 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, key=AZD_AIR_DEMAND, - name="Air Demand", + translation_key="air_demand", ), AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.BATTERY, key=AZD_BATTERY_LOW, - name="Battery Low", ), AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, key=AZD_FLOOR_DEMAND, - name="Floor Demand", + translation_key="floor_demand", ), AirzoneBinarySensorEntityDescription( attributes={ @@ -72,7 +69,6 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, key=AZD_PROBLEMS, - name="Problem", ), ) @@ -149,7 +145,6 @@ class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): ) -> None: """Initialize.""" super().__init__(coordinator, entry, system_data) - self._attr_name = f"System {system_id} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}" self.entity_description = description self._async_update_attrs() @@ -169,7 +164,6 @@ class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index c3ba74236bd..b4cf3d9d522 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -19,7 +19,6 @@ from aioairzone.const import ( AZD_MASTER, AZD_MODE, AZD_MODES, - AZD_NAME, AZD_ON, AZD_SPEED, AZD_SPEEDS, @@ -114,6 +113,7 @@ async def async_setup_entry( class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Define an Airzone sensor.""" + _attr_name = None _speeds: dict[int, str] = {} _speeds_reverse: dict[str, int] = {} @@ -127,7 +127,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Initialize Airzone climate entity.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]}" self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE self._attr_target_temperature_step = API_TEMPERATURE_STEP diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 2310d5fb5a4..b758acd4b75 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -39,6 +39,8 @@ _LOGGER = logging.getLogger(__name__) class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]): """Define an Airzone entity.""" + _attr_has_entity_name = True + def get_airzone_value(self, key: str) -> Any: """Return Airzone entity value by key.""" raise NotImplementedError() @@ -62,7 +64,7 @@ class AirzoneSystemEntity(AirzoneEntity): identifiers={(DOMAIN, f"{entry.entry_id}_{self.system_id}")}, manufacturer=MANUFACTURER, model=self.get_airzone_value(AZD_MODEL), - name=self.get_airzone_value(AZD_FULL_NAME), + name=f"System {self.system_id}", sw_version=self.get_airzone_value(AZD_FIRMWARE), via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) @@ -172,7 +174,7 @@ class AirzoneZoneEntity(AirzoneEntity): identifiers={(DOMAIN, f"{entry.entry_id}_{system_zone_id}")}, manufacturer=MANUFACTURER, model=self.get_airzone_value(AZD_THERMOSTAT_MODEL), - name=f"Airzone [{system_zone_id}] {zone_data[AZD_NAME]}", + name=zone_data[AZD_NAME], sw_version=self.get_airzone_value(AZD_THERMOSTAT_FW), via_device=(DOMAIN, f"{entry.entry_id}_{self.system_id}"), ) diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 1a0d577bb35..78b4dee3b72 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -11,7 +11,6 @@ from aioairzone.const import ( API_SLEEP, AZD_COLD_ANGLE, AZD_HEAT_ANGLE, - AZD_NAME, AZD_SLEEP, AZD_ZONES, ) @@ -60,7 +59,6 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( api_param=API_COLD_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_COLD_ANGLE, - name="Cold Angle", options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, translation_key="grille_angles", @@ -69,16 +67,14 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( api_param=API_HEAT_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_HEAT_ANGLE, - name="Heat Angle", options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, - translation_key="grille_angles", + translation_key="heat_angles", ), AirzoneSelectDescription( api_param=API_SLEEP, entity_category=EntityCategory.CONFIG, key=AZD_SLEEP, - name="Sleep", options=list(SLEEP_DICT), options_dict=SLEEP_DICT, translation_key="sleep_times", @@ -146,7 +142,6 @@ class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect): """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index 1dd67294aff..c14eaf48ff1 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -6,7 +6,6 @@ from typing import Any, Final from aioairzone.const import ( AZD_HOT_WATER, AZD_HUMIDITY, - AZD_NAME, AZD_TEMP, AZD_TEMP_UNIT, AZD_WEBSERVER, @@ -54,7 +53,7 @@ WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, key=AZD_WIFI_RSSI, - name="RSSI", + translation_key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, ), @@ -64,14 +63,12 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( device_class=SensorDeviceClass.HUMIDITY, key=AZD_HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -144,8 +141,6 @@ class AirzoneSensor(AirzoneEntity, SensorEntity): class AirzoneHotWaterSensor(AirzoneHotWaterEntity, AirzoneSensor): """Define an Airzone Hot Water sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -176,7 +171,6 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): ) -> None: """Initialize.""" super().__init__(coordinator, entry) - self._attr_name = f"WebServer {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_ws_{description.key}" self.entity_description = description self._async_update_attrs() @@ -196,7 +190,6 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index 037ebe52d78..438304d7f41 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -25,8 +25,17 @@ } }, "entity": { + "binary_sensor": { + "air_demand": { + "name": "Air demand" + }, + "floor_demand": { + "name": "Floor demand" + } + }, "select": { "grille_angles": { + "name": "Cold angle", "state": { "90deg": "90°", "50deg": "50°", @@ -34,7 +43,17 @@ "40deg": "40°" } }, + "heat_angles": { + "name": "Heat angle", + "state": { + "90deg": "[%key:component::airzone::entity::select::grille_angles::state::90deg%]", + "50deg": "[%key:component::airzone::entity::select::grille_angles::state::50deg%]", + "45deg": "[%key:component::airzone::entity::select::grille_angles::state::45deg%]", + "40deg": "[%key:component::airzone::entity::select::grille_angles::state::40deg%]" + } + }, "sleep_times": { + "name": "Sleep", "state": { "off": "[%key:common::state::off%]", "30m": "30 minutes", @@ -42,6 +61,11 @@ "90m": "90 minutes" } } + }, + "sensor": { + "rssi": { + "name": "RSSI" + } } } } diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py index b19aa36449c..58164edf3e9 100644 --- a/homeassistant/components/airzone/water_heater.py +++ b/homeassistant/components/airzone/water_heater.py @@ -9,7 +9,6 @@ from aioairzone.const import ( API_ACS_POWER_MODE, API_ACS_SET_POINT, AZD_HOT_WATER, - AZD_NAME, AZD_OPERATION, AZD_OPERATIONS, AZD_TEMP, @@ -67,6 +66,7 @@ async def async_setup_entry( class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity): """Define an Airzone Water Heater.""" + _attr_name = None _attr_supported_features = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE | WaterHeaterEntityFeature.ON_OFF @@ -81,7 +81,6 @@ class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity): """Initialize Airzone water heater entity.""" super().__init__(coordinator, entry) - self._attr_name = self.get_airzone_value(AZD_NAME) self._attr_unique_id = f"{self._attr_unique_id}_dhw" self._attr_operation_list = [ OPERATION_LIB_TO_HASS[operation] diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index b9ab7198148..9cb6e550711 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -229,6 +229,7 @@ 'mac': '**REDACTED**', 'wifi_channel': 6, 'wifi_rssi': -42, + 'ws_type': 'ws_az', }), }), 'config_entry': dict({ @@ -323,7 +324,9 @@ }), 'version': '1.62', 'webserver': dict({ + 'full-name': 'Airzone WebServer', 'mac': '**REDACTED**', + 'model': 'Airzone WebServer', 'wifi-channel': 6, 'wifi-rssi': -42, }), diff --git a/tests/components/airzone/test_binary_sensor.py b/tests/components/airzone/test_binary_sensor.py index 8033871f5c3..a620a3338c2 100644 --- a/tests/components/airzone/test_binary_sensor.py +++ b/tests/components/airzone/test_binary_sensor.py @@ -21,7 +21,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.despacho_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.despacho_battery_low") + state = hass.states.get("binary_sensor.despacho_battery") assert state.state == STATE_ON state = hass.states.get("binary_sensor.despacho_floor_demand") @@ -34,7 +34,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_1_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dorm_1_battery_low") + state = hass.states.get("binary_sensor.dorm_1_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_1_floor_demand") @@ -46,7 +46,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_2_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dorm_2_battery_low") + state = hass.states.get("binary_sensor.dorm_2_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_2_floor_demand") @@ -58,7 +58,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_ppal_air_demand") assert state.state == STATE_ON - state = hass.states.get("binary_sensor.dorm_ppal_battery_low") + state = hass.states.get("binary_sensor.dorm_ppal_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_ppal_floor_demand") @@ -70,7 +70,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.salon_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.salon_battery_low") + state = hass.states.get("binary_sensor.salon_battery") assert state is None state = hass.states.get("binary_sensor.salon_floor_demand") @@ -79,13 +79,13 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.salon_problem") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.airzone_2_1_battery_low") + state = hass.states.get("binary_sensor.airzone_2_1_battery") assert state is None state = hass.states.get("binary_sensor.airzone_2_1_problem") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dkn_plus_battery_low") + state = hass.states.get("binary_sensor.dkn_plus_battery") assert state is None state = hass.states.get("binary_sensor.dkn_plus_problem") diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 6d94defa004..1511cd4362c 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -34,7 +34,7 @@ async def test_airzone_create_sensors( assert state.state == "43" # WebServer - state = hass.states.get("sensor.webserver_rssi") + state = hass.states.get("sensor.airzone_webserver_rssi") assert state.state == "-42" # Zones diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index eb687731eb7..a3454549e05 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -50,6 +50,8 @@ from aioairzone.const import ( API_VERSION, API_WIFI_CHANNEL, API_WIFI_RSSI, + API_WS_AZ, + API_WS_TYPE, API_ZONE_ID, ) @@ -301,6 +303,7 @@ HVAC_VERSION_MOCK = { HVAC_WEBSERVER_MOCK = { API_MAC: "11:22:33:44:55:66", + API_WS_TYPE: API_WS_AZ, API_WIFI_CHANNEL: 6, API_WIFI_RSSI: -42, } From 7f5896bc4522a1e4ecb9f3f21f65a0bff91d26a1 Mon Sep 17 00:00:00 2001 From: Tom Puttemans Date: Sat, 28 Oct 2023 13:59:24 +0200 Subject: [PATCH 046/982] Add gas device class to dsmr_reader sensor (#102953) DSMR reader integration - can't configure gas meter in energy dashboard posible due to missing device_class Fixes #102367 --- homeassistant/components/dsmr_reader/definitions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 33bba375fd3..d89e30311e9 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -141,6 +141,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( translation_key="gas_meter_usage", entity_registry_enabled_default=False, icon="mdi:fire", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -209,6 +210,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", translation_key="current_gas_usage", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), @@ -283,6 +285,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/gas", translation_key="daily_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -460,6 +463,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-month/gas", translation_key="current_month_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -538,6 +542,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-year/gas", translation_key="current_year_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( From 6f515c06a24fff7084499be42b8963a88ff82951 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 28 Oct 2023 14:52:20 +0200 Subject: [PATCH 047/982] Add test for check_config helper (#102898) --- tests/helpers/test_check_config.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index a3fd02686ac..973dec7381e 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -159,7 +159,7 @@ async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: - """Test no errors if platform not found in recovery_mode.""" + """Test no errors if platform not found in recovery mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} hass.config.recovery_mode = True @@ -173,6 +173,21 @@ async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: assert not res.errors +async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if platform not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant", "light"} + assert res["light"] == [] + + assert not res.errors + + async def test_package_invalid(hass: HomeAssistant) -> None: """Test a valid platform setup.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + ' packages:\n p1:\n group: ["a"]'} From 524e20536d6d4920157600d7dd3c7cdc42a49c68 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 28 Oct 2023 14:53:34 +0200 Subject: [PATCH 048/982] Improve dlna_dmr tests (#102905) --- tests/components/dlna_dmr/conftest.py | 5 ++-- tests/components/dlna_dmr/test_config_flow.py | 23 ++++++++----------- .../components/dlna_dmr/test_media_player.py | 3 +-- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py index 81225173d51..9e9bcbf3056 100644 --- a/tests/components/dlna_dmr/conftest.py +++ b/tests/components/dlna_dmr/conftest.py @@ -74,8 +74,8 @@ def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]: seal(upnp_device) domain_data.upnp_factory.async_create_device.return_value = upnp_device - with patch.dict(hass.data, {DLNA_DOMAIN: domain_data}): - yield domain_data + hass.data[DLNA_DOMAIN] = domain_data + return domain_data @pytest.fixture @@ -129,6 +129,7 @@ def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: device.manufacturer = "device_manufacturer" device.model_name = "device_model_name" device.name = "device_name" + device.preset_names = ["preset1", "preset2"] yield device diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index be49a6ca257..d9b1d60708b 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -97,6 +97,15 @@ def mock_get_mac_address() -> Iterable[Mock]: yield gma_mock +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Iterable[Mock]: + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.dlna_dmr.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: """Test user-init'd flow, no discovered devices, user entering a valid URL.""" result = await hass.config_entries.flow.async_init( @@ -120,9 +129,6 @@ async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_discovered_manual( hass: HomeAssistant, ssdp_scanner_mock: Mock @@ -163,9 +169,6 @@ async def test_user_flow_discovered_manual( } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None: """Test user-init'd flow, user selects discovered device.""" @@ -196,8 +199,6 @@ async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) } assert result["options"] == {} - await hass.async_block_till_done() - async def test_user_flow_uncontactable( hass: HomeAssistant, domain_data_mock: Mock @@ -260,9 +261,6 @@ async def test_user_flow_embedded_st( } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) -> None: """Test user-init'd config flow with user entering a URL for the wrong device.""" @@ -717,9 +715,6 @@ async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> No } assert result["options"] == {} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_unignore_flow_offline( hass: HomeAssistant, ssdp_scanner_mock: Mock diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index f8413e8f620..51128b161fb 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -26,7 +26,6 @@ from homeassistant.components.dlna_dmr.const import ( CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, - DOMAIN as DLNA_DOMAIN, ) from homeassistant.components.dlna_dmr.data import EventListenAddr from homeassistant.components.dlna_dmr.media_player import DlnaDmrEntity @@ -81,7 +80,7 @@ pytestmark = pytest.mark.usefixtures("domain_data_mock") async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry) -> str: """Set up a mock DlnaDmrEntity with the given configuration.""" mock_entry.add_to_hass(hass) - assert await async_setup_component(hass, DLNA_DOMAIN, {}) is True + assert await hass.config_entries.async_setup(mock_entry.entry_id) is True await hass.async_block_till_done() entries = async_entries_for_config_entry(async_get_er(hass), mock_entry.entry_id) From 009dc91b97c6dfd5a134b49844f44d3d537da1df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Oct 2023 08:38:42 -0500 Subject: [PATCH 049/982] Fix inner callback decorators with partials (#102873) --- .../components/websocket_api/commands.py | 33 +++++++++-------- homeassistant/core.py | 16 +++++++-- homeassistant/helpers/event.py | 4 +-- tests/test_core.py | 36 +++++++++++++++++++ 4 files changed, 68 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a29bee86116..b69ff57d015 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -92,6 +92,7 @@ def pong_message(iden: int) -> dict[str, Any]: return {"id": iden, "type": "pong"} +@callback def _forward_events_check_permissions( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], user: User, @@ -109,6 +110,7 @@ def _forward_events_check_permissions( send_message(messages.cached_event_message(msg_id, event)) +@callback def _forward_events_unconditional( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], msg_id: int, @@ -135,17 +137,15 @@ def handle_subscribe_events( raise Unauthorized if event_type == EVENT_STATE_CHANGED: - forward_events = callback( - partial( - _forward_events_check_permissions, - connection.send_message, - connection.user, - msg["id"], - ) + forward_events = partial( + _forward_events_check_permissions, + connection.send_message, + connection.user, + msg["id"], ) else: - forward_events = callback( - partial(_forward_events_unconditional, connection.send_message, msg["id"]) + forward_events = partial( + _forward_events_unconditional, connection.send_message, msg["id"] ) connection.subscriptions[msg["id"]] = hass.bus.async_listen( @@ -298,6 +298,7 @@ def _send_handle_get_states_response( connection.send_message(construct_result_message(msg_id, f"[{joined_states}]")) +@callback def _forward_entity_changes( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], entity_ids: set[str], @@ -337,14 +338,12 @@ def handle_subscribe_entities( states = _async_get_allowed_states(hass, connection) connection.subscriptions[msg["id"]] = hass.bus.async_listen( EVENT_STATE_CHANGED, - callback( - partial( - _forward_entity_changes, - connection.send_message, - entity_ids, - connection.user, - msg["id"], - ) + partial( + _forward_entity_changes, + connection.send_message, + entity_ids, + connection.user, + msg["id"], ), run_immediately=True, ) diff --git a/homeassistant/core.py b/homeassistant/core.py index 2025d813be4..48cc70e7727 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -209,6 +209,18 @@ def is_callback(func: Callable[..., Any]) -> bool: return getattr(func, "_hass_callback", False) is True +def is_callback_check_partial(target: Callable[..., Any]) -> bool: + """Check if function is safe to be called in the event loop. + + This version of is_callback will also check if the target is a partial + and walk the chain of partials to find the original function. + """ + check_target = target + while isinstance(check_target, functools.partial): + check_target = check_target.func + return is_callback(check_target) + + class _Hass(threading.local): """Container which makes a HomeAssistant instance available to the event loop.""" @@ -1141,9 +1153,9 @@ class EventBus: This method must be run in the event loop. """ - if event_filter is not None and not is_callback(event_filter): + if event_filter is not None and not is_callback_check_partial(event_filter): raise HomeAssistantError(f"Event filter {event_filter} is not a callback") - if run_immediately and not is_callback(listener): + if run_immediately and not is_callback_check_partial(listener): raise HomeAssistantError(f"Event listener {listener} is not a callback") return self._async_listen_filterable_job( event_type, diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 75e2340a187..ab0fc25f04d 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -394,8 +394,8 @@ def _async_track_event( if listeners_key not in hass_data: hass_data[listeners_key] = hass.bus.async_listen( event_type, - callback(ft.partial(dispatcher_callable, hass, callbacks)), - event_filter=callback(ft.partial(filter_callable, hass, callbacks)), + ft.partial(dispatcher_callable, hass, callbacks), + event_filter=ft.partial(filter_callable, hass, callbacks), ) job = HassJob(action, f"track {event_type} event {keys}") diff --git a/tests/test_core.py b/tests/test_core.py index 957da634dce..9fed1141a76 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2498,3 +2498,39 @@ async def test_get_release_channel(version: str, release_channel: str) -> None: """Test if release channel detection works from Home Assistant version number.""" with patch("homeassistant.core.__version__", f"{version}"): assert get_release_channel() == release_channel + + +def test_is_callback_check_partial(): + """Test is_callback_check_partial matches HassJob.""" + + @ha.callback + def callback_func(): + pass + + def not_callback_func(): + pass + + assert ha.is_callback(callback_func) + assert HassJob(callback_func).job_type == ha.HassJobType.Callback + assert ha.is_callback_check_partial(functools.partial(callback_func)) + assert HassJob(functools.partial(callback_func)).job_type == ha.HassJobType.Callback + assert ha.is_callback_check_partial( + functools.partial(functools.partial(callback_func)) + ) + assert HassJob(functools.partial(functools.partial(callback_func))).job_type == ( + ha.HassJobType.Callback + ) + assert not ha.is_callback_check_partial(not_callback_func) + assert HassJob(not_callback_func).job_type == ha.HassJobType.Executor + assert not ha.is_callback_check_partial(functools.partial(not_callback_func)) + assert HassJob(functools.partial(not_callback_func)).job_type == ( + ha.HassJobType.Executor + ) + + # We check the inner function, not the outer one + assert not ha.is_callback_check_partial( + ha.callback(functools.partial(not_callback_func)) + ) + assert HassJob(ha.callback(functools.partial(not_callback_func))).job_type == ( + ha.HassJobType.Executor + ) From 7d598801fe230ef054910a85ed7395640d968f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 28 Oct 2023 16:56:26 +0300 Subject: [PATCH 050/982] Update prettier to 3.0.3 (#102929) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9cca711131..77b16568eb4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + rev: v3.0.3 hooks: - id: prettier - repo: https://github.com/cdce8p/python-typing-update From 18fa5b853274afc2d3607732902145415d4e1ace Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Oct 2023 09:18:09 -0500 Subject: [PATCH 051/982] Small cleanups to mobile_app encryption (#102883) --- .../components/mobile_app/helpers.py | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index e9bb3af51f2..9265b72d0d0 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -36,45 +36,49 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def setup_decrypt(key_encoder) -> tuple[int, Callable]: +def setup_decrypt( + key_encoder: type[RawEncoder] | type[HexEncoder], +) -> Callable[[bytes, bytes], bytes]: """Return decryption function and length of key. Async friendly. """ - def decrypt(ciphertext, key): + def decrypt(ciphertext: bytes, key: bytes) -> bytes: """Decrypt ciphertext using key.""" return SecretBox(key, encoder=key_encoder).decrypt( ciphertext, encoder=Base64Encoder ) - return (SecretBox.KEY_SIZE, decrypt) + return decrypt -def setup_encrypt(key_encoder) -> tuple[int, Callable]: +def setup_encrypt( + key_encoder: type[RawEncoder] | type[HexEncoder], +) -> Callable[[bytes, bytes], bytes]: """Return encryption function and length of key. Async friendly. """ - def encrypt(ciphertext, key): + def encrypt(ciphertext: bytes, key: bytes) -> bytes: """Encrypt ciphertext using key.""" return SecretBox(key, encoder=key_encoder).encrypt( ciphertext, encoder=Base64Encoder ) - return (SecretBox.KEY_SIZE, encrypt) + return encrypt def _decrypt_payload_helper( - key: str | None, - ciphertext: str, - get_key_bytes: Callable[[str, int], str | bytes], - key_encoder, + key: str | bytes, + ciphertext: bytes, + key_bytes: bytes, + key_encoder: type[RawEncoder] | type[HexEncoder], ) -> JsonValueType | None: """Decrypt encrypted payload.""" try: - keylen, decrypt = setup_decrypt(key_encoder) + decrypt = setup_decrypt(key_encoder) except OSError: _LOGGER.warning("Ignoring encrypted payload because libsodium not installed") return None @@ -83,33 +87,31 @@ def _decrypt_payload_helper( _LOGGER.warning("Ignoring encrypted payload because no decryption key known") return None - key_bytes = get_key_bytes(key, keylen) - msg_bytes = decrypt(ciphertext, key_bytes) message = json_loads(msg_bytes) _LOGGER.debug("Successfully decrypted mobile_app payload") return message -def decrypt_payload(key: str | None, ciphertext: str) -> JsonValueType | None: +def decrypt_payload(key: str, ciphertext: bytes) -> JsonValueType | None: """Decrypt encrypted payload.""" - - def get_key_bytes(key: str, keylen: int) -> str: - return key - - return _decrypt_payload_helper(key, ciphertext, get_key_bytes, HexEncoder) + return _decrypt_payload_helper(key, ciphertext, key.encode("utf-8"), HexEncoder) -def decrypt_payload_legacy(key: str | None, ciphertext: str) -> JsonValueType | None: +def _convert_legacy_encryption_key(key: str) -> bytes: + """Convert legacy encryption key.""" + keylen = SecretBox.KEY_SIZE + key_bytes = key.encode("utf-8") + key_bytes = key_bytes[:keylen] + key_bytes = key_bytes.ljust(keylen, b"\0") + return key_bytes + + +def decrypt_payload_legacy(key: str, ciphertext: bytes) -> JsonValueType | None: """Decrypt encrypted payload.""" - - def get_key_bytes(key: str, keylen: int) -> bytes: - key_bytes = key.encode("utf-8") - key_bytes = key_bytes[:keylen] - key_bytes = key_bytes.ljust(keylen, b"\0") - return key_bytes - - return _decrypt_payload_helper(key, ciphertext, get_key_bytes, RawEncoder) + return _decrypt_payload_helper( + key, ciphertext, _convert_legacy_encryption_key(key), RawEncoder + ) def registration_context(registration: Mapping[str, Any]) -> Context: @@ -184,16 +186,14 @@ def webhook_response( json_data = json_bytes(data) if registration[ATTR_SUPPORTS_ENCRYPTION]: - keylen, encrypt = setup_encrypt( + encrypt = setup_encrypt( HexEncoder if ATTR_NO_LEGACY_ENCRYPTION in registration else RawEncoder ) if ATTR_NO_LEGACY_ENCRYPTION in registration: key: bytes = registration[CONF_SECRET] else: - key = registration[CONF_SECRET].encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b"\0") + key = _convert_legacy_encryption_key(registration[CONF_SECRET]) enc_data = encrypt(json_data, key).decode("utf-8") json_data = json_bytes({"encrypted": True, "encrypted_data": enc_data}) From 5648dc6cd121a29482677a90c3c07499040db008 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Oct 2023 09:18:25 -0500 Subject: [PATCH 052/982] Reduce string copy needed to subscribe to entities (#102870) --- homeassistant/components/websocket_api/commands.py | 10 +++++----- homeassistant/components/websocket_api/http.py | 3 +-- homeassistant/components/websocket_api/messages.py | 5 ----- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index b69ff57d015..7d59fd39a0c 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -53,7 +53,7 @@ from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages from .connection import ActiveConnection -from .messages import construct_event_message, construct_result_message +from .messages import construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @@ -294,8 +294,9 @@ def _send_handle_get_states_response( connection: ActiveConnection, msg_id: int, serialized_states: list[str] ) -> None: """Send handle get states response.""" - joined_states = ",".join(serialized_states) - connection.send_message(construct_result_message(msg_id, f"[{joined_states}]")) + connection.send_message( + construct_result_message(msg_id, f'[{",".join(serialized_states)}]') + ) @callback @@ -383,9 +384,8 @@ def _send_handle_entities_init_response( connection: ActiveConnection, msg_id: int, serialized_states: list[str] ) -> None: """Send handle entities init response.""" - joined_states = ",".join(serialized_states) connection.send_message( - construct_event_message(msg_id, f'{{"a":{{{joined_states}}}}}') + f'{{"id":{msg_id},"type":"event","event":{{"a":{{{",".join(serialized_states)}}}}}}}' ) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 238cd6d7465..f2f667368c3 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -159,8 +159,7 @@ class WebSocketHandler: messages.append(message) messages_remaining -= 1 - joined_messages = ",".join(messages) - coalesced_messages = f"[{joined_messages}]" + coalesced_messages = f'[{",".join(messages)}]' if debug_enabled: debug("%s: Sending %s", self.description, coalesced_messages) await send_str(coalesced_messages) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index e1b038f4222..12e649219bc 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -74,11 +74,6 @@ def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: } -def construct_event_message(iden: int, payload: str) -> str: - """Construct an event message JSON.""" - return f'{{"id":{iden},"type":"event","event":{payload}}}' - - def event_message(iden: int, event: Any) -> dict[str, Any]: """Return an event message.""" return {"id": iden, "type": "event", "event": event} From 7e4e124f503e332fcce612c933b2cafc2b74ddbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 28 Oct 2023 16:32:31 +0200 Subject: [PATCH 053/982] Move has entity name to parent entity in Airzone Cloud (#102961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit airzone_cloud: consolidate _attr_has_entity_name Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/binary_sensor.py | 6 ------ homeassistant/components/airzone_cloud/climate.py | 1 - homeassistant/components/airzone_cloud/entity.py | 2 ++ homeassistant/components/airzone_cloud/sensor.py | 6 ------ 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index a364ad0d753..2a182b7b487 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -159,8 +159,6 @@ class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity): class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor): """Define an Airzone Cloud Aidoo binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -180,8 +178,6 @@ class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor): class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): """Define an Airzone Cloud System binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -201,8 +197,6 @@ class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): """Define an Airzone Cloud Zone binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 1fe5e45ee44..53bc7e89a3c 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -142,7 +142,6 @@ async def async_setup_entry( class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" - _attr_has_entity_name = True _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index d5dd0cfcfb4..297f85af359 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -34,6 +34,8 @@ _LOGGER = logging.getLogger(__name__) class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Define an Airzone Cloud entity.""" + _attr_has_entity_name = True + @property def available(self) -> bool: """Return Airzone Cloud entity availability.""" diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index c33838029b4..f45fd248cd5 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -141,8 +141,6 @@ class AirzoneSensor(AirzoneEntity, SensorEntity): class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): """Define an Airzone Cloud Aidoo sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -162,8 +160,6 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): """Define an Airzone Cloud WebServer sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -183,8 +179,6 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Define an Airzone Cloud Zone sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, From 03d3a87f2374ad16ef204c649a21811ad8086f11 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 28 Oct 2023 17:16:41 +0200 Subject: [PATCH 054/982] Small cleanup of legacy groups (#102918) * Small cleanup of legacy groups * Update tests which create groups --- homeassistant/components/group/__init__.py | 93 +++---- .../device_sun_light_trigger/test_init.py | 11 +- tests/components/group/test_init.py | 248 +++++++++++++++--- tests/components/zwave_js/test_services.py | 62 ++++- tests/helpers/test_service.py | 9 +- tests/helpers/test_template.py | 42 ++- 6 files changed, 370 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 82c2651e764..1092bc5834b 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -294,7 +294,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service: ServiceCall) -> None: """Remove all user-defined groups and load new ones from config.""" - auto = [e for e in component.entities if not e.user_defined] + auto = [e for e in component.entities if e.created_by_service] if (conf := await component.async_prepare_reload()) is None: return @@ -329,20 +329,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: or None ) - extra_arg = { - attr: service.data[attr] - for attr in (ATTR_ICON,) - if service.data.get(attr) is not None - } - await Group.async_create_group( hass, service.data.get(ATTR_NAME, object_id), - object_id=object_id, + created_by_service=True, entity_ids=entity_ids, - user_defined=False, + icon=service.data.get(ATTR_ICON), mode=service.data.get(ATTR_ALL), - **extra_arg, + object_id=object_id, + order=None, ) return @@ -449,7 +444,8 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None Group.async_create_group_entity( hass, name, - entity_ids, + created_by_service=False, + entity_ids=entity_ids, icon=icon, object_id=object_id, mode=mode, @@ -570,11 +566,12 @@ class Group(Entity): self, hass: HomeAssistant, name: str, - order: int | None = None, - icon: str | None = None, - user_defined: bool = True, - entity_ids: Collection[str] | None = None, - mode: bool | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + order: int | None, ) -> None: """Initialize a group. @@ -588,7 +585,7 @@ class Group(Entity): self._on_off: dict[str, bool] = {} self._assumed: dict[str, bool] = {} self._on_states: set[str] = set() - self.user_defined = user_defined + self.created_by_service = created_by_service self.mode = any if mode: self.mode = all @@ -596,36 +593,18 @@ class Group(Entity): self._assumed_state = False self._async_unsub_state_changed: CALLBACK_TYPE | None = None - @staticmethod - def create_group( - hass: HomeAssistant, - name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, - ) -> Group: - """Initialize a group.""" - return asyncio.run_coroutine_threadsafe( - Group.async_create_group( - hass, name, entity_ids, user_defined, icon, object_id, mode, order - ), - hass.loop, - ).result() - @staticmethod @callback def async_create_group_entity( hass: HomeAssistant, name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + object_id: str | None, + order: int | None, ) -> Group: """Create a group entity.""" if order is None: @@ -639,11 +618,11 @@ class Group(Entity): group = Group( hass, name, - order=order, - icon=icon, - user_defined=user_defined, + created_by_service=created_by_service, entity_ids=entity_ids, + icon=icon, mode=mode, + order=order, ) group.entity_id = async_generate_entity_id( @@ -656,19 +635,27 @@ class Group(Entity): async def async_create_group( hass: HomeAssistant, name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + object_id: str | None, + order: int | None, ) -> Group: """Initialize a group. This method must be run in the event loop. """ group = Group.async_create_group_entity( - hass, name, entity_ids, user_defined, icon, object_id, mode, order + hass, + name, + created_by_service=created_by_service, + entity_ids=entity_ids, + icon=icon, + mode=mode, + object_id=object_id, + order=order, ) # If called before the platform async_setup is called (test cases) @@ -704,7 +691,7 @@ class Group(Entity): def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes for the group.""" data = {ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order} - if not self.user_defined: + if self.created_by_service: data[ATTR_AUTO] = True return data diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 6b563f1cb5f..724ae612f0d 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -182,7 +182,16 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "person_me", ["person.me"]) + await group.Group.async_create_group( + hass, + "person_me", + created_by_service=False, + entity_ids=["person.me"], + icon=None, + mode=None, + object_id=None, + order=None, + ) assert await async_setup_component( hass, diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 3ea75fbce06..c439506b52a 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -39,7 +39,14 @@ async def test_setup_group_with_mixed_groupable_states(hass: HomeAssistant) -> N assert await async_setup_component(hass, "group", {}) await group.Group.async_create_group( - hass, "person_and_light", ["light.Bowl", "device_tracker.Paulus"] + hass, + "person_and_light", + created_by_service=False, + entity_ids=["light.Bowl", "device_tracker.Paulus"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -54,7 +61,14 @@ async def test_setup_group_with_a_non_existing_state(hass: HomeAssistant) -> Non assert await async_setup_component(hass, "group", {}) grp = await group.Group.async_create_group( - hass, "light_and_nothing", ["light.Bowl", "non.existing"] + hass, + "light_and_nothing", + created_by_service=False, + entity_ids=["light.Bowl", "non.existing"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert grp.state == STATE_ON @@ -68,7 +82,14 @@ async def test_setup_group_with_non_groupable_states(hass: HomeAssistant) -> Non assert await async_setup_component(hass, "group", {}) grp = await group.Group.async_create_group( - hass, "chromecasts", ["cast.living_room", "cast.bedroom"] + hass, + "chromecasts", + created_by_service=False, + entity_ids=["cast.living_room", "cast.bedroom"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert grp.state is None @@ -76,7 +97,16 @@ async def test_setup_group_with_non_groupable_states(hass: HomeAssistant) -> Non async def test_setup_empty_group(hass: HomeAssistant) -> None: """Try to set up an empty group.""" - grp = await group.Group.async_create_group(hass, "nothing", []) + grp = await group.Group.async_create_group( + hass, + "nothing", + created_by_service=False, + entity_ids=[], + icon=None, + mode=None, + object_id=None, + order=None, + ) assert grp.state is None @@ -89,7 +119,14 @@ async def test_monitor_group(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) # Test if group setup in our init mode is ok @@ -108,7 +145,14 @@ async def test_group_turns_off_if_all_off(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -127,7 +171,14 @@ async def test_group_turns_on_if_all_are_off_and_one_turns_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) # Turn one on @@ -148,7 +199,14 @@ async def test_allgroup_stays_off_if_all_are_off_and_one_turns_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False, mode=True + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=True, + object_id=None, + order=None, ) # Turn one on @@ -167,7 +225,14 @@ async def test_allgroup_turn_on_if_last_turns_on(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False, mode=True + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=True, + object_id=None, + order=None, ) # Turn one on @@ -186,7 +251,14 @@ async def test_expand_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert sorted(["light.ceiling", "light.bowl"]) == sorted( @@ -204,7 +276,14 @@ async def test_expand_entity_ids_does_not_return_duplicates( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["light.bowl", "light.ceiling"] == sorted( @@ -226,8 +305,12 @@ async def test_expand_entity_ids_recursive(hass: HomeAssistant) -> None: test_group = await group.Group.async_create_group( hass, "init_group", - ["light.Bowl", "light.Ceiling", "group.init_group"], - False, + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling", "group.init_group"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert sorted(["light.ceiling", "light.bowl"]) == sorted( @@ -248,7 +331,14 @@ async def test_get_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["light.bowl", "light.ceiling"] == sorted( @@ -263,7 +353,14 @@ async def test_get_entity_ids_with_domain_filter(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) mixed_group = await group.Group.async_create_group( - hass, "mixed_group", ["light.Bowl", "switch.AC"], False + hass, + "mixed_group", + created_by_service=True, + entity_ids=["light.Bowl", "switch.AC"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["switch.ac"] == group.get_entity_ids( @@ -293,7 +390,14 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "test group", ["light.not_there_1"] + hass, + "test group", + created_by_service=False, + entity_ids=["light.not_there_1"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("light.not_there_1", STATE_ON) @@ -314,7 +418,14 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_off( """ assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "test group", ["light.not_there_1"] + hass, + "test group", + created_by_service=False, + entity_ids=["light.not_there_1"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("light.not_there_1", STATE_OFF) @@ -330,8 +441,26 @@ async def test_groups_get_unique_names(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) - grp1 = await group.Group.async_create_group(hass, "Je suis Charlie") - grp2 = await group.Group.async_create_group(hass, "Je suis Charlie") + grp1 = await group.Group.async_create_group( + hass, + "Je suis Charlie", + created_by_service=False, + entity_ids=None, + icon=None, + mode=None, + object_id=None, + order=None, + ) + grp2 = await group.Group.async_create_group( + hass, + "Je suis Charlie", + created_by_service=False, + entity_ids=None, + icon=None, + mode=None, + object_id=None, + order=None, + ) assert grp1.entity_id != grp2.entity_id @@ -342,13 +471,34 @@ async def test_expand_entity_ids_expands_nested_groups(hass: HomeAssistant) -> N assert await async_setup_component(hass, "group", {}) await group.Group.async_create_group( - hass, "light", ["light.test_1", "light.test_2"] + hass, + "light", + created_by_service=False, + entity_ids=["light.test_1", "light.test_2"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( - hass, "switch", ["switch.test_1", "switch.test_2"] + hass, + "switch", + created_by_service=False, + entity_ids=["switch.test_1", "switch.test_2"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( - hass, "group_of_groups", ["group.light", "group.switch"] + hass, + "group_of_groups", + created_by_service=False, + entity_ids=["group.light", "group.switch"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert [ @@ -367,7 +517,14 @@ async def test_set_assumed_state_based_on_tracked(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling", "sensor.no_exist"] + hass, + "init_group", + created_by_service=False, + entity_ids=["light.Bowl", "light.Ceiling", "sensor.no_exist"], + icon=None, + mode=None, + object_id=None, + order=None, ) state = hass.states.get(test_group.entity_id) @@ -398,7 +555,14 @@ async def test_group_updated_after_device_tracker_zone_change( assert await async_setup_component(hass, "device_tracker", {}) await group.Group.async_create_group( - hass, "peeps", ["device_tracker.Adam", "device_tracker.Eve"] + hass, + "peeps", + created_by_service=False, + entity_ids=["device_tracker.Adam", "device_tracker.Eve"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("device_tracker.Adam", "cool_state_not_home") @@ -417,7 +581,14 @@ async def test_is_on(hass: HomeAssistant) -> None: await hass.async_block_till_done() test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -446,7 +617,14 @@ async def test_reloading_groups(hass: HomeAssistant) -> None: await hass.async_block_till_done() await group.Group.async_create_group( - hass, "all tests", ["test.one", "test.two"], user_defined=False + hass, + "all tests", + created_by_service=True, + entity_ids=["test.one", "test.two"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -523,14 +701,24 @@ async def test_setup(hass: HomeAssistant) -> None: await hass.async_block_till_done() test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( hass, "created_group", - ["light.Bowl", f"{test_group.entity_id}"], - True, - "mdi:work", + created_by_service=False, + entity_ids=["light.Bowl", f"{test_group.entity_id}"], + icon="mdi:work", + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index ccbe956fbe5..84d9b457d18 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -224,7 +224,16 @@ async def test_set_config_parameter( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[AIR_TEMPERATURE_SENSOR], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -594,7 +603,16 @@ async def test_bulk_set_config_parameters( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[AIR_TEMPERATURE_SENSOR], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -728,7 +746,16 @@ async def test_refresh_value( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [CLIMATE_RADIO_THERMOSTAT_ENTITY]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_RADIO_THERMOSTAT_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, + ) client.async_send_command.return_value = {"result": 2} await hass.services.async_call( DOMAIN, @@ -848,7 +875,16 @@ async def test_set_value( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_SET_VALUE, @@ -1150,7 +1186,14 @@ async def test_multicast_set_value( # Test groups get expanded for multicast call assert await async_setup_component(hass, "group", {}) await Group.async_create_group( - hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY] + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.services.async_call( DOMAIN, @@ -1516,7 +1559,14 @@ async def test_ping( # Test groups get expanded for multicast call assert await async_setup_component(hass, "group", {}) await Group.async_create_group( - hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY] + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.services.async_call( DOMAIN, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 03a8b5e11b2..04324cdbfa3 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -465,7 +465,14 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() await hass.components.group.Group.async_create_group( - hass, "test", ["light.Ceiling", "light.Kitchen"] + hass, + "test", + created_by_service=False, + entity_ids=["light.Ceiling", "light.Kitchen"], + icon=None, + mode=None, + object_id=None, + order=None, ) call = ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index c466bfed213..5f7ef594909 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2649,7 +2649,16 @@ async def test_closest_function_home_vs_group_entity_id(hass: HomeAssistant) -> assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "location group", ["test_domain.object"]) + await group.Group.async_create_group( + hass, + "location group", + created_by_service=False, + entity_ids=["test_domain.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') assert_result_info( @@ -2677,7 +2686,16 @@ async def test_closest_function_home_vs_group_state(hass: HomeAssistant) -> None assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "location group", ["test_domain.object"]) + await group.Group.async_create_group( + hass, + "location group", + created_by_service=False, + entity_ids=["test_domain.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') assert_result_info( @@ -2727,7 +2745,16 @@ async def test_expand(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "new group", ["test.object"]) + await group.Group.async_create_group( + hass, + "new group", + created_by_service=False, + entity_ids=["test.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info( hass, @@ -2769,7 +2796,14 @@ async def test_expand(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() await group.Group.async_create_group( - hass, "power sensors", ["sensor.power_1", "sensor.power_2", "sensor.power_3"] + hass, + "power sensors", + created_by_service=False, + entity_ids=["sensor.power_1", "sensor.power_2", "sensor.power_3"], + icon=None, + mode=None, + object_id=None, + order=None, ) info = render_to_info( From 8703621c642d99e2fdc9c8a3f35c94dfe04fe0dd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 Oct 2023 08:20:44 -0700 Subject: [PATCH 055/982] Improve fitbit oauth import robustness (#102833) * Improve fitbit oauth import robustness * Improve sensor tests and remove unnecessary client check * Fix oauth client id/secret config key checks * Add executor for sync call --- homeassistant/components/fitbit/sensor.py | 71 +++++++++++++-------- tests/components/fitbit/test_config_flow.py | 59 +++++++++++++++++ tests/components/fitbit/test_sensor.py | 15 ++++- 3 files changed, 118 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 45b8ea21b0e..4885c9fa16d 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -8,6 +8,8 @@ import logging import os from typing import Any, Final, cast +from fitbit import Fitbit +from oauthlib.oauth2.rfc6749.errors import OAuth2Error import voluptuous as vol from homeassistant.components.application_credentials import ( @@ -567,34 +569,51 @@ async def async_setup_platform( if config_file is not None: _LOGGER.debug("Importing existing fitbit.conf application credentials") - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] - ), + + # Refresh the token before importing to ensure it is working and not + # expired on first initialization. + authd_client = Fitbit( + config_file[CONF_CLIENT_ID], + config_file[CONF_CLIENT_SECRET], + access_token=config_file[ATTR_ACCESS_TOKEN], + refresh_token=config_file[ATTR_REFRESH_TOKEN], + expires_at=config_file[ATTR_LAST_SAVED_AT], + refresh_cb=lambda x: None, ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - "auth_implementation": DOMAIN, - CONF_TOKEN: { - ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], - "expires_at": config_file[ATTR_LAST_SAVED_AT], - }, - CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], - CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], - CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], - }, - ) - translation_key = "deprecated_yaml_import" - if ( - result.get("type") == FlowResultType.ABORT - and result.get("reason") == "cannot_connect" - ): + try: + await hass.async_add_executor_job(authd_client.client.refresh_token) + except OAuth2Error as err: + _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) translation_key = "deprecated_yaml_import_issue_cannot_connect" + else: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] + ), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], + "expires_at": config_file[ATTR_LAST_SAVED_AT], + }, + CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], + CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], + CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], + }, + ) + translation_key = "deprecated_yaml_import" + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + translation_key = "deprecated_yaml_import_issue_cannot_connect" else: translation_key = "deprecated_yaml_no_import" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index e6ab39aff59..152439ec19a 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -209,9 +209,17 @@ async def test_import_fitbit_config( fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, + requests_mock: Mocker, ) -> None: """Test that platform configuration is imported successfully.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: @@ -256,6 +264,12 @@ async def test_import_fitbit_config_failure_cannot_connect( ) -> None: """Test platform configuration fails to import successfully.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -273,6 +287,43 @@ async def test_import_fitbit_config_failure_cannot_connect( assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" +@pytest.mark.parametrize( + "status_code", + [ + (HTTPStatus.UNAUTHORIZED), + (HTTPStatus.INTERNAL_SERVER_ERROR), + ], +) +async def test_import_fitbit_config_cannot_refresh( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, + status_code: HTTPStatus, +) -> None: + """Test platform configuration import fails when refreshing the token.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=status_code, + json="", + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 0 + + # Verify an issue is raised that we were unable to import configuration + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + + async def test_import_fitbit_config_already_exists( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -281,9 +332,17 @@ async def test_import_fitbit_config_already_exists( fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, + requests_mock: Mocker, ) -> None: """Test that platform configuration is not imported if it already exists.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + # Verify existing config entry entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index b54f154d406..5421a652125 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -9,7 +9,7 @@ import pytest from requests_mock.mocker import Mocker from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_TOKEN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,6 +23,7 @@ from homeassistant.util.unit_system import ( from .conftest import ( DEVICES_API_URL, PROFILE_USER_ID, + SERVER_ACCESS_TOKEN, TIMESERIES_API_URL_FORMAT, timeseries_response, ) @@ -55,6 +56,18 @@ def platforms() -> list[str]: return [Platform.SENSOR] +@pytest.fixture(autouse=True) +def mock_token_refresh(requests_mock: Mocker) -> None: + """Test that platform configuration is imported successfully.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + + @pytest.mark.parametrize( ( "monitored_resources", From fb5d058885ade9b6a8214d880911001ff688580f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 28 Oct 2023 20:53:40 +0200 Subject: [PATCH 056/982] Add AEMET library data to coordinator and use it for weather platform (#102954) --- homeassistant/components/aemet/const.py | 48 ++++ homeassistant/components/aemet/entity.py | 23 ++ homeassistant/components/aemet/weather.py | 120 +++------- .../aemet/weather_update_coordinator.py | 37 ++- .../aemet/snapshots/test_weather.ambr | 219 +++++++++++++----- tests/components/aemet/test_weather.py | 64 ++--- 6 files changed, 336 insertions(+), 175 deletions(-) create mode 100644 homeassistant/components/aemet/entity.py diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 7940ff92f72..c3328fc1b5d 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -12,6 +12,18 @@ from aemet_opendata.const import ( AOD_COND_RAINY, AOD_COND_SNOWY, AOD_COND_SUNNY, + AOD_CONDITION, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_PRECIPITATION, + AOD_PRECIPITATION_PROBABILITY, + AOD_TEMP, + AOD_TEMP_MAX, + AOD_TEMP_MIN, + AOD_TIMESTAMP, + AOD_WIND_DIRECTION, + AOD_WIND_SPEED, + AOD_WIND_SPEED_MAX, ) from homeassistant.components.weather import ( @@ -25,6 +37,15 @@ from homeassistant.components.weather import ( ATTR_CONDITION_RAINY, ATTR_CONDITION_SNOWY, ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, ) from homeassistant.const import Platform @@ -122,3 +143,30 @@ FORECAST_MODE_ATTR_API = { FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } + +FORECAST_MAP = { + AOD_FORECAST_DAILY: { + AOD_CONDITION: ATTR_FORECAST_CONDITION, + AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + AOD_TEMP_MAX: ATTR_FORECAST_NATIVE_TEMP, + AOD_TEMP_MIN: ATTR_FORECAST_NATIVE_TEMP_LOW, + AOD_TIMESTAMP: ATTR_FORECAST_TIME, + AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING, + AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, + AOD_FORECAST_HOURLY: { + AOD_CONDITION: ATTR_FORECAST_CONDITION, + AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + AOD_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, + AOD_TEMP: ATTR_FORECAST_NATIVE_TEMP, + AOD_TIMESTAMP: ATTR_FORECAST_TIME, + AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING, + AOD_WIND_SPEED_MAX: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, +} + +WEATHER_FORECAST_MODES = { + AOD_FORECAST_DAILY: "daily", + AOD_FORECAST_HOURLY: "hourly", +} diff --git a/homeassistant/components/aemet/entity.py b/homeassistant/components/aemet/entity.py new file mode 100644 index 00000000000..527ff046104 --- /dev/null +++ b/homeassistant/components/aemet/entity.py @@ -0,0 +1,23 @@ +"""Entity classes for the AEMET OpenData integration.""" +from __future__ import annotations + +from typing import Any + +from aemet_opendata.helpers import dict_nested_value + +from homeassistant.components.weather import Forecast +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .weather_update_coordinator import WeatherUpdateCoordinator + + +class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]): + """Define an AEMET entity.""" + + def get_aemet_forecast(self, forecast_mode: str) -> list[Forecast]: + """Return AEMET entity forecast by mode.""" + return self.coordinator.data["forecast"][forecast_mode] + + def get_aemet_value(self, keys: list[str]) -> Any: + """Return AEMET entity value by keys.""" + return dict_nested_value(self.coordinator.data["lib"], keys) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index 03f91a74740..b7b3c31ab5b 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,16 +1,19 @@ """Support for the AEMET OpenData service.""" -from typing import cast + +from aemet_opendata.const import ( + AOD_CONDITION, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_HUMIDITY, + AOD_PRESSURE, + AOD_TEMP, + AOD_WEATHER, + AOD_WIND_DIRECTION, + AOD_WIND_SPEED, + AOD_WIND_SPEED_MAX, +) from homeassistant.components.weather import ( - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, Forecast, SingleCoordinatorWeatherEntity, @@ -28,55 +31,16 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_API_CONDITION, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_MAX_SPEED, - ATTR_API_FORECAST_WIND_SPEED, - ATTR_API_HUMIDITY, - ATTR_API_PRESSURE, - ATTR_API_TEMPERATURE, - ATTR_API_WIND_BEARING, - ATTR_API_WIND_MAX_SPEED, - ATTR_API_WIND_SPEED, ATTRIBUTION, + CONDITIONS_MAP, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODE_ATTR_API, - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, - FORECAST_MODES, + WEATHER_FORECAST_MODES, ) +from .entity import AemetEntity from .weather_update_coordinator import WeatherUpdateCoordinator -FORECAST_MAP = { - FORECAST_MODE_DAILY: { - ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, - }, - FORECAST_MODE_HOURLY: { - ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_MAX_SPEED: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, - }, -} - async def async_setup_entry( hass: HomeAssistant, @@ -95,11 +59,11 @@ async def async_setup_entry( if entity_registry.async_get_entity_id( WEATHER_DOMAIN, DOMAIN, - f"{config_entry.unique_id} {FORECAST_MODE_HOURLY}", + f"{config_entry.unique_id} {WEATHER_FORECAST_MODES[AOD_FORECAST_HOURLY]}", ): - for mode in FORECAST_MODES: - name = f"{domain_data[ENTRY_NAME]} {mode}" - unique_id = f"{config_entry.unique_id} {mode}" + for mode, mode_id in WEATHER_FORECAST_MODES.items(): + name = f"{domain_data[ENTRY_NAME]} {mode_id}" + unique_id = f"{config_entry.unique_id} {mode_id}" entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) else: entities.append( @@ -107,15 +71,18 @@ async def async_setup_entry( domain_data[ENTRY_NAME], config_entry.unique_id, weather_coordinator, - FORECAST_MODE_DAILY, + AOD_FORECAST_DAILY, ) ) async_add_entities(entities, False) -class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): - """Implementation of an AEMET OpenData sensor.""" +class AemetWeather( + AemetEntity, + SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator], +): + """Implementation of an AEMET OpenData weather.""" _attr_attribution = ATTRIBUTION _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -137,7 +104,7 @@ class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): super().__init__(coordinator) self._forecast_mode = forecast_mode self._attr_entity_registry_enabled_default = ( - self._forecast_mode == FORECAST_MODE_DAILY + self._forecast_mode == AOD_FORECAST_DAILY ) self._attr_name = name self._attr_unique_id = unique_id @@ -145,61 +112,50 @@ class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): @property def condition(self): """Return the current condition.""" - return self.coordinator.data[ATTR_API_CONDITION] - - def _forecast(self, forecast_mode: str) -> list[Forecast]: - """Return the forecast array.""" - forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[forecast_mode]] - forecast_map = FORECAST_MAP[forecast_mode] - return cast( - list[Forecast], - [ - {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} - for forecast in forecasts - ], - ) + cond = self.get_aemet_value([AOD_WEATHER, AOD_CONDITION]) + return CONDITIONS_MAP.get(cond) @property def forecast(self) -> list[Forecast]: """Return the forecast array.""" - return self._forecast(self._forecast_mode) + return self.get_aemet_forecast(self._forecast_mode) @callback def _async_forecast_daily(self) -> list[Forecast]: """Return the daily forecast in native units.""" - return self._forecast(FORECAST_MODE_DAILY) + return self.get_aemet_forecast(AOD_FORECAST_DAILY) @callback def _async_forecast_hourly(self) -> list[Forecast]: """Return the hourly forecast in native units.""" - return self._forecast(FORECAST_MODE_HOURLY) + return self.get_aemet_forecast(AOD_FORECAST_HOURLY) @property def humidity(self): """Return the humidity.""" - return self.coordinator.data[ATTR_API_HUMIDITY] + return self.get_aemet_value([AOD_WEATHER, AOD_HUMIDITY]) @property def native_pressure(self): """Return the pressure.""" - return self.coordinator.data[ATTR_API_PRESSURE] + return self.get_aemet_value([AOD_WEATHER, AOD_PRESSURE]) @property def native_temperature(self): """Return the temperature.""" - return self.coordinator.data[ATTR_API_TEMPERATURE] + return self.get_aemet_value([AOD_WEATHER, AOD_TEMP]) @property def wind_bearing(self): """Return the wind bearing.""" - return self.coordinator.data[ATTR_API_WIND_BEARING] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_DIRECTION]) @property def native_wind_gust_speed(self): """Return the wind gust speed in native units.""" - return self.coordinator.data[ATTR_API_WIND_MAX_SPEED] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED_MAX]) @property def native_wind_speed(self): """Return the wind speed.""" - return self.coordinator.data[ATTR_API_WIND_SPEED] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED]) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 01c2502fb37..cd95a8e0854 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta import logging -from typing import Any, Final +from typing import Any, Final, cast from aemet_opendata.const import ( AEMET_ATTR_DATE, @@ -31,17 +31,24 @@ from aemet_opendata.const import ( AEMET_ATTR_TEMPERATURE, AEMET_ATTR_WIND, AEMET_ATTR_WIND_GUST, + AOD_CONDITION, + AOD_FORECAST, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_TOWN, ATTR_DATA, ) from aemet_opendata.exceptions import AemetError from aemet_opendata.forecast import ForecastValue from aemet_opendata.helpers import ( + dict_nested_value, get_forecast_day_value, get_forecast_hour_value, get_forecast_interval_value, ) from aemet_opendata.interface import AEMET +from homeassistant.components.weather import Forecast from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -79,6 +86,7 @@ from .const import ( ATTR_API_WIND_SPEED, CONDITIONS_MAP, DOMAIN, + FORECAST_MAP, ) _LOGGER = logging.getLogger(__name__) @@ -239,6 +247,12 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): weather_response, now ) + data = self.aemet.data() + forecasts: list[dict[str, Forecast]] = { + AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY), + AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY), + } + return { ATTR_API_CONDITION: condition, ATTR_API_FORECAST_DAILY: forecast_daily, @@ -261,8 +275,29 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_API_WIND_BEARING: wind_bearing, ATTR_API_WIND_MAX_SPEED: wind_max_speed, ATTR_API_WIND_SPEED: wind_speed, + "forecast": forecasts, + "lib": data, } + def aemet_forecast( + self, + data: dict[str, Any], + forecast_mode: str, + ) -> list[Forecast]: + """Return the forecast array.""" + forecasts = dict_nested_value(data, [AOD_TOWN, forecast_mode, AOD_FORECAST]) + forecast_map = FORECAST_MAP[forecast_mode] + forecast_list: list[dict[str, Any]] = [] + for forecast in forecasts: + cur_forecast: dict[str, Any] = {} + for api_key, ha_key in forecast_map.items(): + value = forecast[api_key] + if api_key == AOD_CONDITION: + value = CONDITIONS_MAP.get(value) + cur_forecast[ha_key] = value + forecast_list += [cur_forecast] + return cast(list[Forecast], forecast_list) + def _get_daily_forecast_from_weather_response(self, weather_response, now): if weather_response.daily: parse = False diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr index 3078cab4480..08cc379267d 100644 --- a/tests/components/aemet/snapshots/test_weather.ambr +++ b/tests/components/aemet/snapshots/test_weather.ambr @@ -14,7 +14,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -23,7 +23,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -31,7 +31,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -39,7 +39,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -47,7 +47,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -151,6 +151,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -160,6 +161,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -169,6 +171,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -178,7 +181,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -187,6 +191,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -196,6 +201,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -205,6 +211,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -214,6 +221,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -223,6 +231,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -232,7 +241,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -241,6 +251,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -250,6 +261,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -259,6 +271,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -268,6 +281,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -277,6 +291,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -286,7 +301,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -295,6 +311,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -304,6 +321,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -313,6 +331,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -322,6 +341,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -331,6 +351,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -340,7 +361,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -349,7 +371,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -358,7 +381,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -367,7 +391,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -376,7 +401,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -385,7 +411,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -394,7 +421,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -403,7 +431,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -412,7 +441,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -421,7 +451,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -430,7 +461,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -439,7 +471,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -471,7 +504,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -480,7 +513,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -488,7 +521,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -496,7 +529,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -504,7 +537,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -525,7 +558,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -534,7 +567,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -542,7 +575,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -550,7 +583,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -558,7 +591,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -660,6 +693,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -669,6 +703,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -678,6 +713,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -687,7 +723,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -696,6 +733,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -705,6 +743,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -714,6 +753,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -723,6 +763,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -732,6 +773,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -741,7 +783,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -750,6 +793,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -759,6 +803,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -768,6 +813,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -777,6 +823,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -786,6 +833,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -795,7 +843,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -804,6 +853,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -813,6 +863,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -822,6 +873,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -831,6 +883,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -840,6 +893,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -849,7 +903,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -858,7 +913,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -867,7 +923,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -876,7 +933,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -885,7 +943,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -894,7 +953,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -903,7 +963,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -912,7 +973,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -921,7 +983,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -930,7 +993,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -939,7 +1003,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -948,7 +1013,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -1060,6 +1126,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -1069,6 +1136,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -1078,6 +1146,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -1087,7 +1156,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -1096,6 +1166,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -1105,6 +1176,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -1114,6 +1186,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -1123,6 +1196,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1132,6 +1206,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -1141,7 +1216,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -1150,6 +1226,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -1159,6 +1236,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1168,6 +1246,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1177,6 +1256,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -1186,6 +1266,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -1195,7 +1276,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -1204,6 +1286,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1213,6 +1296,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1222,6 +1306,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -1231,6 +1316,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1240,6 +1326,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -1249,7 +1336,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -1258,7 +1346,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -1267,7 +1356,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -1276,7 +1366,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -1285,7 +1376,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -1294,7 +1386,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -1303,7 +1396,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -1312,7 +1406,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -1321,7 +1416,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -1330,7 +1426,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -1339,7 +1436,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -1348,7 +1446,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index d0042faaaa0..67cdbe7805d 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -54,25 +54,25 @@ async def test_aemet_weather( state = hass.states.get("weather.aemet") assert state assert state.state == ATTR_CONDITION_SNOWY - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 - assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa - assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 - assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 - assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h - forecast = state.attributes.get(ATTR_FORECAST)[0] - assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY - assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None - assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 - assert forecast.get(ATTR_FORECAST_TEMP) == 4 - assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 99.0 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1004.4 # 100440.0 Pa -> hPa + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == -0.7 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 122.0 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 12.2 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 3.2 + forecast = state.attributes[ATTR_FORECAST][0] + assert forecast[ATTR_FORECAST_CONDITION] == ATTR_CONDITION_PARTLYCLOUDY + assert ATTR_FORECAST_PRECIPITATION not in forecast + assert forecast[ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 30 + assert forecast[ATTR_FORECAST_TEMP] == 4 + assert forecast[ATTR_FORECAST_TEMP_LOW] == -4 assert ( - forecast.get(ATTR_FORECAST_TIME) + forecast[ATTR_FORECAST_TIME] == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) - assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h + assert forecast[ATTR_FORECAST_WIND_BEARING] == 45.0 + assert forecast[ATTR_FORECAST_WIND_SPEED] == 20.0 # 5.56 m/s -> km/h state = hass.states.get("weather.aemet_hourly") assert state is None @@ -98,25 +98,25 @@ async def test_aemet_weather_legacy( state = hass.states.get("weather.aemet_daily") assert state assert state.state == ATTR_CONDITION_SNOWY - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 - assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa - assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 - assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 - assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h - forecast = state.attributes.get(ATTR_FORECAST)[0] - assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY - assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None - assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 - assert forecast.get(ATTR_FORECAST_TEMP) == 4 - assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 99.0 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1004.4 # 100440.0 Pa -> hPa + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == -0.7 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 122.0 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 12.2 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 3.2 + forecast = state.attributes[ATTR_FORECAST][0] + assert forecast[ATTR_FORECAST_CONDITION] == ATTR_CONDITION_PARTLYCLOUDY + assert ATTR_FORECAST_PRECIPITATION not in forecast + assert forecast[ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 30 + assert forecast[ATTR_FORECAST_TEMP] == 4 + assert forecast[ATTR_FORECAST_TEMP_LOW] == -4 assert ( - forecast.get(ATTR_FORECAST_TIME) + forecast[ATTR_FORECAST_TIME] == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) - assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h + assert forecast[ATTR_FORECAST_WIND_BEARING] == 45.0 + assert forecast[ATTR_FORECAST_WIND_SPEED] == 20.0 # 5.56 m/s -> km/h state = hass.states.get("weather.aemet_hourly") assert state is None From efc9f845dbc0b9311b03961043585f7bf66bf9be Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 Oct 2023 12:02:42 -0700 Subject: [PATCH 057/982] Fix error message strings for Todoist configuration flow (#102968) * Fix error message strings for Todoist configuration flow * Update error code in test --- homeassistant/components/todoist/config_flow.py | 2 +- homeassistant/components/todoist/strings.json | 6 ++++-- tests/components/todoist/test_config_flow.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index 6098df40ea0..b8c79210dfb 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -44,7 +44,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await api.get_tasks() except HTTPError as err: if err.response.status_code == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_access_token" + errors["base"] = "invalid_api_key" else: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 68c2305d073..442114eb118 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -9,10 +9,12 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py index 4175902da31..141f12269de 100644 --- a/tests/components/todoist/test_config_flow.py +++ b/tests/components/todoist/test_config_flow.py @@ -69,7 +69,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("errors") == {"base": "invalid_access_token"} + assert result2.get("errors") == {"base": "invalid_api_key"} @pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) From b1aeaf2296b875215304cb6f30c8ec353089d2e4 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 28 Oct 2023 21:31:43 +0200 Subject: [PATCH 058/982] Update xknxproject to 3.4.0 (#102946) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index b5c98c7203a..a233ca38705 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.11.2", - "xknxproject==3.3.0", + "xknxproject==3.4.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 6635c4b27ff..36e81817f87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2740,7 +2740,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 876233d0ab6..e5ecb8d1f23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2043,7 +2043,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz From a4c31f63bf3d1e92c64c3778153c91441b13bc40 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 29 Oct 2023 05:59:02 +1000 Subject: [PATCH 059/982] Add current temperature to master climate entity in Advantage Air (#102938) * Add current_temperature * Update tests --- homeassistant/components/advantage_air/climate.py | 7 +++++++ tests/components/advantage_air/test_climate.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index a4e0a1033ba..8244472f2b4 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -122,6 +122,13 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): if self._ac.get(ADVANTAGE_AIR_AUTOFAN): self._attr_fan_modes += [FAN_AUTO] + @property + def current_temperature(self) -> float | None: + """Return the selected zones current temperature.""" + if self._myzone: + return self._myzone["measuredTemp"] + return None + @property def target_temperature(self) -> float | None: """Return the current target temperature.""" diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index b045092d78d..f5f12e48a40 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -73,7 +73,7 @@ async def test_climate_async_setup_entry( assert state.attributes.get(ATTR_MIN_TEMP) == 16 assert state.attributes.get(ATTR_MAX_TEMP) == 32 assert state.attributes.get(ATTR_TEMPERATURE) == 24 - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) is None + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25 entry = registry.async_get(entity_id) assert entry From 4599b788b45d94ac5165e6a014be8de68b9af0c7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 Oct 2023 15:35:31 -0700 Subject: [PATCH 060/982] Update caldav to use an DataUpdateCoordinator for fetching data (#102089) --- homeassistant/components/caldav/calendar.py | 114 ++++++++++++-------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 4fe5e38432a..30557391e0d 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -24,12 +24,16 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -103,16 +107,14 @@ def setup_platform( name = cust_calendar[CONF_NAME] device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}" entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - calendar_devices.append( - WebDavCalendarEntity( - name=name, - calendar=calendar, - entity_id=entity_id, - days=days, - all_day=True, - search=cust_calendar[CONF_SEARCH], - ) + coordinator = CalDavUpdateCoordinator( + hass, + calendar=calendar, + days=days, + include_all_day=True, + search=cust_calendar[CONF_SEARCH], ) + calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator)) # Create a default calendar if there was no custom one for all calendars # that support events. @@ -130,24 +132,26 @@ def setup_platform( name = calendar.name device_id = calendar.name entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - calendar_devices.append( - WebDavCalendarEntity(name, calendar, entity_id, days) + coordinator = CalDavUpdateCoordinator( + hass, + calendar=calendar, + days=days, + include_all_day=False, + search=None, ) + calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator)) add_entities(calendar_devices, True) -class WebDavCalendarEntity(CalendarEntity): +class WebDavCalendarEntity( + CoordinatorEntity["CalDavUpdateCoordinator"], CalendarEntity +): """A device for getting the next Task from a WebDav Calendar.""" - def __init__(self, name, calendar, entity_id, days, all_day=False, search=None): + def __init__(self, name, entity_id, coordinator): """Create the WebDav Calendar Event Device.""" - self.data = WebDavCalendarData( - calendar=calendar, - days=days, - include_all_day=all_day, - search=search, - ) + super().__init__(coordinator) self.entity_id = entity_id self._event: CalendarEvent | None = None self._attr_name = name @@ -161,31 +165,42 @@ class WebDavCalendarEntity(CalendarEntity): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - return await self.data.async_get_events(hass, start_date, end_date) + return await self.coordinator.async_get_events(hass, start_date, end_date) - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Update event data.""" - self.data.update() - self._event = self.data.event + self._event = self.coordinator.data self._attr_extra_state_attributes = { "offset_reached": is_offset_reached( - self._event.start_datetime_local, self.data.offset + self._event.start_datetime_local, self.coordinator.offset ) if self._event else False } + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() -class WebDavCalendarData: +class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): """Class to utilize the calendar dav client object to get next event.""" - def __init__(self, calendar, days, include_all_day, search): + def __init__(self, hass, calendar, days, include_all_day, search): """Set up how we are going to search the WebDav calendar.""" + super().__init__( + hass, + _LOGGER, + name=f"CalDAV {calendar.name}", + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) self.calendar = calendar self.days = days self.include_all_day = include_all_day self.search = search - self.event = None self.offset = None async def async_get_events( @@ -222,19 +237,21 @@ class WebDavCalendarData: return event_list - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + async def _async_update_data(self) -> CalendarEvent | None: """Get the latest data.""" start_of_today = dt_util.start_of_local_day() start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) # We have to retrieve the results for the whole day as the server # won't return events that have already started - results = self.calendar.search( - start=start_of_today, - end=start_of_tomorrow, - event=True, - expand=True, + results = await self.hass.async_add_executor_job( + partial( + self.calendar.search, + start=start_of_today, + end=start_of_tomorrow, + event=True, + expand=True, + ), ) # Create new events for each recurrence of an event that happens today. @@ -247,12 +264,15 @@ class WebDavCalendarData: continue vevent = event.instance.vevent for start_dt in vevent.getrruleset() or []: - _start_of_today = start_of_today - _start_of_tomorrow = start_of_tomorrow + _start_of_today: date | datetime + _start_of_tomorrow: datetime | date if self.is_all_day(vevent): start_dt = start_dt.date() - _start_of_today = _start_of_today.date() - _start_of_tomorrow = _start_of_tomorrow.date() + _start_of_today = start_of_today.date() + _start_of_tomorrow = start_of_tomorrow.date() + else: + _start_of_today = start_of_today + _start_of_tomorrow = start_of_tomorrow if _start_of_today <= start_dt < _start_of_tomorrow: new_event = event.copy() new_vevent = new_event.instance.vevent @@ -293,21 +313,21 @@ class WebDavCalendarData: len(vevents), self.calendar.name, ) - self.event = None - return + self.offset = None + return None # Populate the entity attributes with the event values (summary, offset) = extract_offset( self.get_attr_value(vevent, "summary") or "", OFFSET ) - self.event = CalendarEvent( + self.offset = offset + return CalendarEvent( summary=summary, start=self.to_local(vevent.dtstart.value), end=self.to_local(self.get_end_date(vevent)), location=self.get_attr_value(vevent, "location"), description=self.get_attr_value(vevent, "description"), ) - self.offset = offset @staticmethod def is_matching(vevent, search): @@ -333,15 +353,15 @@ class WebDavCalendarData: @staticmethod def is_over(vevent): """Return if the event is over.""" - return dt_util.now() >= WebDavCalendarData.to_datetime( - WebDavCalendarData.get_end_date(vevent) + return dt_util.now() >= CalDavUpdateCoordinator.to_datetime( + CalDavUpdateCoordinator.get_end_date(vevent) ) @staticmethod def to_datetime(obj): """Return a datetime.""" if isinstance(obj, datetime): - return WebDavCalendarData.to_local(obj) + return CalDavUpdateCoordinator.to_local(obj) return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) @staticmethod From 6202f178afe2746e70ce35e2e0835cc529ad20dd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 Oct 2023 03:32:42 +0100 Subject: [PATCH 061/982] Fix proximity zone handling (#102971) * fix proximity zone * fix test --- homeassistant/components/proximity/__init__.py | 12 ++++++------ tests/components/proximity/test_init.py | 8 ++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index a4520435161..07b5f931f79 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -70,22 +70,22 @@ def async_setup_proximity_component( ignored_zones: list[str] = config[CONF_IGNORED_ZONES] proximity_devices: list[str] = config[CONF_DEVICES] tolerance: int = config[CONF_TOLERANCE] - proximity_zone = name + proximity_zone = config[CONF_ZONE] unit_of_measurement: str = config.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) - zone_id = f"zone.{config[CONF_ZONE]}" + zone_friendly_name = name proximity = Proximity( hass, - proximity_zone, + zone_friendly_name, DEFAULT_DIST_TO_ZONE, DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST, ignored_zones, proximity_devices, tolerance, - zone_id, + proximity_zone, unit_of_measurement, ) proximity.entity_id = f"{DOMAIN}.{proximity_zone}" @@ -171,7 +171,7 @@ class Proximity(Entity): devices_to_calculate = False devices_in_zone = "" - zone_state = self.hass.states.get(self.proximity_zone) + zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") proximity_latitude = ( zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None ) @@ -189,7 +189,7 @@ class Proximity(Entity): devices_to_calculate = True # Check the location of all devices. - if (device_state.state).lower() == (self.friendly_name).lower(): + if (device_state.state).lower() == (self.proximity_zone).lower(): device_friendly = device_state.name if devices_in_zone != "": devices_in_zone = f"{devices_in_zone}, " diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 34f87b5c261..0ec8765e604 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -13,7 +13,11 @@ async def test_proximities(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"devices": ["device_tracker.test1"], "tolerance": "1"}, + "work": { + "devices": ["device_tracker.test1"], + "tolerance": "1", + "zone": "work", + }, } } @@ -42,7 +46,7 @@ async def test_proximities_setup(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"tolerance": "1"}, + "work": {"tolerance": "1", "zone": "work"}, } } From 82688d2a33674004173cfc05d771eec530a35869 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 29 Oct 2023 00:05:37 -0700 Subject: [PATCH 062/982] Bump opower to 0.0.38 (#102983) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 88e03842504..a27d6f6f680 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.37"] + "requirements": ["opower==0.0.38"] } diff --git a/requirements_all.txt b/requirements_all.txt index 36e81817f87..32058cfac31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1394,7 +1394,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.37 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5ecb8d1f23..20085dd9f19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1072,7 +1072,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.37 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 From 4e229584864ddd07242912e6a0ac2b13c0101f5c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Oct 2023 01:04:09 -0700 Subject: [PATCH 063/982] Move caldav coordinator to its own file (#102976) * Move caldav coordinator to its own file. * Remove unused offset. --- homeassistant/components/caldav/calendar.py | 236 +----------------- .../components/caldav/coordinator.py | 234 +++++++++++++++++ 2 files changed, 239 insertions(+), 231 deletions(-) create mode 100644 homeassistant/components/caldav/coordinator.py diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 30557391e0d..14c9626c264 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,10 +1,8 @@ """Support for WebDav Calendar.""" from __future__ import annotations -from datetime import date, datetime, time, timedelta -from functools import partial +from datetime import datetime import logging -import re import caldav import voluptuous as vol @@ -14,7 +12,6 @@ from homeassistant.components.calendar import ( PLATFORM_SCHEMA, CalendarEntity, CalendarEvent, - extract_offset, is_offset_reached, ) from homeassistant.const import ( @@ -29,11 +26,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from homeassistant.util import dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import CalDavUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,7 +38,6 @@ CONF_CALENDAR = "calendar" CONF_SEARCH = "search" CONF_DAYS = "days" -OFFSET = "!!" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -68,8 +62,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - def setup_platform( hass: HomeAssistant, @@ -144,9 +136,7 @@ def setup_platform( add_entities(calendar_devices, True) -class WebDavCalendarEntity( - CoordinatorEntity["CalDavUpdateCoordinator"], CalendarEntity -): +class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity): """A device for getting the next Task from a WebDav Calendar.""" def __init__(self, name, entity_id, coordinator): @@ -184,219 +174,3 @@ class WebDavCalendarEntity( """When entity is added to hass update state from existing coordinator data.""" await super().async_added_to_hass() self._handle_coordinator_update() - - -class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): - """Class to utilize the calendar dav client object to get next event.""" - - def __init__(self, hass, calendar, days, include_all_day, search): - """Set up how we are going to search the WebDav calendar.""" - super().__init__( - hass, - _LOGGER, - name=f"CalDAV {calendar.name}", - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) - self.calendar = calendar - self.days = days - self.include_all_day = include_all_day - self.search = search - self.offset = None - - async def async_get_events( - self, hass: HomeAssistant, start_date: datetime, end_date: datetime - ) -> list[CalendarEvent]: - """Get all events in a specific time frame.""" - # Get event list from the current calendar - vevent_list = await hass.async_add_executor_job( - partial( - self.calendar.search, - start=start_date, - end=end_date, - event=True, - expand=True, - ) - ) - event_list = [] - for event in vevent_list: - if not hasattr(event.instance, "vevent"): - _LOGGER.warning("Skipped event with missing 'vevent' property") - continue - vevent = event.instance.vevent - if not self.is_matching(vevent, self.search): - continue - event_list.append( - CalendarEvent( - summary=self.get_attr_value(vevent, "summary") or "", - start=self.to_local(vevent.dtstart.value), - end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), - ) - ) - - return event_list - - async def _async_update_data(self) -> CalendarEvent | None: - """Get the latest data.""" - start_of_today = dt_util.start_of_local_day() - start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) - - # We have to retrieve the results for the whole day as the server - # won't return events that have already started - results = await self.hass.async_add_executor_job( - partial( - self.calendar.search, - start=start_of_today, - end=start_of_tomorrow, - event=True, - expand=True, - ), - ) - - # Create new events for each recurrence of an event that happens today. - # For recurring events, some servers return the original event with recurrence rules - # and they would not be properly parsed using their original start/end dates. - new_events = [] - for event in results: - if not hasattr(event.instance, "vevent"): - _LOGGER.warning("Skipped event with missing 'vevent' property") - continue - vevent = event.instance.vevent - for start_dt in vevent.getrruleset() or []: - _start_of_today: date | datetime - _start_of_tomorrow: datetime | date - if self.is_all_day(vevent): - start_dt = start_dt.date() - _start_of_today = start_of_today.date() - _start_of_tomorrow = start_of_tomorrow.date() - else: - _start_of_today = start_of_today - _start_of_tomorrow = start_of_tomorrow - if _start_of_today <= start_dt < _start_of_tomorrow: - new_event = event.copy() - new_vevent = new_event.instance.vevent - if hasattr(new_vevent, "dtend"): - dur = new_vevent.dtend.value - new_vevent.dtstart.value - new_vevent.dtend.value = start_dt + dur - new_vevent.dtstart.value = start_dt - new_events.append(new_event) - elif _start_of_tomorrow <= start_dt: - break - vevents = [ - event.instance.vevent - for event in results + new_events - if hasattr(event.instance, "vevent") - ] - - # dtstart can be a date or datetime depending if the event lasts a - # whole day. Convert everything to datetime to be able to sort it - vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) - - vevent = next( - ( - vevent - for vevent in vevents - if ( - self.is_matching(vevent, self.search) - and (not self.is_all_day(vevent) or self.include_all_day) - and not self.is_over(vevent) - ) - ), - None, - ) - - # If no matching event could be found - if vevent is None: - _LOGGER.debug( - "No matching event found in the %d results for %s", - len(vevents), - self.calendar.name, - ) - self.offset = None - return None - - # Populate the entity attributes with the event values - (summary, offset) = extract_offset( - self.get_attr_value(vevent, "summary") or "", OFFSET - ) - self.offset = offset - return CalendarEvent( - summary=summary, - start=self.to_local(vevent.dtstart.value), - end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), - ) - - @staticmethod - def is_matching(vevent, search): - """Return if the event matches the filter criteria.""" - if search is None: - return True - - pattern = re.compile(search) - return ( - hasattr(vevent, "summary") - and pattern.match(vevent.summary.value) - or hasattr(vevent, "location") - and pattern.match(vevent.location.value) - or hasattr(vevent, "description") - and pattern.match(vevent.description.value) - ) - - @staticmethod - def is_all_day(vevent): - """Return if the event last the whole day.""" - return not isinstance(vevent.dtstart.value, datetime) - - @staticmethod - def is_over(vevent): - """Return if the event is over.""" - return dt_util.now() >= CalDavUpdateCoordinator.to_datetime( - CalDavUpdateCoordinator.get_end_date(vevent) - ) - - @staticmethod - def to_datetime(obj): - """Return a datetime.""" - if isinstance(obj, datetime): - return CalDavUpdateCoordinator.to_local(obj) - return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) - - @staticmethod - def to_local(obj: datetime | date) -> datetime | date: - """Return a datetime as a local datetime, leaving dates unchanged. - - This handles giving floating times a timezone for comparison - with all day events and dropping the custom timezone object - used by the caldav client and dateutil so the datetime can be copied. - """ - if isinstance(obj, datetime): - return dt_util.as_local(obj) - return obj - - @staticmethod - def get_attr_value(obj, attribute): - """Return the value of the attribute if defined.""" - if hasattr(obj, attribute): - return getattr(obj, attribute).value - return None - - @staticmethod - def get_end_date(obj): - """Return the end datetime as determined by dtend or duration.""" - if hasattr(obj, "dtend"): - enddate = obj.dtend.value - elif hasattr(obj, "duration"): - enddate = obj.dtstart.value + obj.duration.value - else: - enddate = obj.dtstart.value + timedelta(days=1) - - # End date for an all day event is exclusive. This fixes the case where - # an all day event has a start and end values are the same, or the event - # has a zero duration. - if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: - enddate += timedelta(days=1) - - return enddate diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py new file mode 100644 index 00000000000..ee34a56e23b --- /dev/null +++ b/homeassistant/components/caldav/coordinator.py @@ -0,0 +1,234 @@ +"""Data update coordinator for caldav.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from functools import partial +import logging +import re + +from homeassistant.components.calendar import CalendarEvent, extract_offset +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +OFFSET = "!!" + + +class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, hass, calendar, days, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + super().__init__( + hass, + _LOGGER, + name=f"CalDAV {calendar.name}", + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + self.calendar = calendar + self.days = days + self.include_all_day = include_all_day + self.search = search + self.offset = None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + # Get event list from the current calendar + vevent_list = await hass.async_add_executor_job( + partial( + self.calendar.search, + start=start_date, + end=end_date, + event=True, + expand=True, + ) + ) + event_list = [] + for event in vevent_list: + if not hasattr(event.instance, "vevent"): + _LOGGER.warning("Skipped event with missing 'vevent' property") + continue + vevent = event.instance.vevent + if not self.is_matching(vevent, self.search): + continue + event_list.append( + CalendarEvent( + summary=self.get_attr_value(vevent, "summary") or "", + start=self.to_local(vevent.dtstart.value), + end=self.to_local(self.get_end_date(vevent)), + location=self.get_attr_value(vevent, "location"), + description=self.get_attr_value(vevent, "description"), + ) + ) + + return event_list + + async def _async_update_data(self) -> CalendarEvent | None: + """Get the latest data.""" + start_of_today = dt_util.start_of_local_day() + start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) + + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = await self.hass.async_add_executor_job( + partial( + self.calendar.search, + start=start_of_today, + end=start_of_tomorrow, + event=True, + expand=True, + ), + ) + + # Create new events for each recurrence of an event that happens today. + # For recurring events, some servers return the original event with recurrence rules + # and they would not be properly parsed using their original start/end dates. + new_events = [] + for event in results: + if not hasattr(event.instance, "vevent"): + _LOGGER.warning("Skipped event with missing 'vevent' property") + continue + vevent = event.instance.vevent + for start_dt in vevent.getrruleset() or []: + _start_of_today: date | datetime + _start_of_tomorrow: datetime | date + if self.is_all_day(vevent): + start_dt = start_dt.date() + _start_of_today = start_of_today.date() + _start_of_tomorrow = start_of_tomorrow.date() + else: + _start_of_today = start_of_today + _start_of_tomorrow = start_of_tomorrow + if _start_of_today <= start_dt < _start_of_tomorrow: + new_event = event.copy() + new_vevent = new_event.instance.vevent + if hasattr(new_vevent, "dtend"): + dur = new_vevent.dtend.value - new_vevent.dtstart.value + new_vevent.dtend.value = start_dt + dur + new_vevent.dtstart.value = start_dt + new_events.append(new_event) + elif _start_of_tomorrow <= start_dt: + break + vevents = [ + event.instance.vevent + for event in results + new_events + if hasattr(event.instance, "vevent") + ] + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) + + vevent = next( + ( + vevent + for vevent in vevents + if ( + self.is_matching(vevent, self.search) + and (not self.is_all_day(vevent) or self.include_all_day) + and not self.is_over(vevent) + ) + ), + None, + ) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(vevents), + self.calendar.name, + ) + self.offset = None + return None + + # Populate the entity attributes with the event values + (summary, offset) = extract_offset( + self.get_attr_value(vevent, "summary") or "", OFFSET + ) + self.offset = offset + return CalendarEvent( + summary=summary, + start=self.to_local(vevent.dtstart.value), + end=self.to_local(self.get_end_date(vevent)), + location=self.get_attr_value(vevent, "location"), + description=self.get_attr_value(vevent, "description"), + ) + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter criteria.""" + if search is None: + return True + + pattern = re.compile(search) + return ( + hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value) + ) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt_util.now() >= CalDavUpdateCoordinator.to_datetime( + CalDavUpdateCoordinator.get_end_date(vevent) + ) + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return CalDavUpdateCoordinator.to_local(obj) + return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + + @staticmethod + def to_local(obj: datetime | date) -> datetime | date: + """Return a datetime as a local datetime, leaving dates unchanged. + + This handles giving floating times a timezone for comparison + with all day events and dropping the custom timezone object + used by the caldav client and dateutil so the datetime can be copied. + """ + if isinstance(obj, datetime): + return dt_util.as_local(obj) + return obj + + @staticmethod + def get_attr_value(obj, attribute): + """Return the value of the attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None + + @staticmethod + def get_end_date(obj): + """Return the end datetime as determined by dtend or duration.""" + if hasattr(obj, "dtend"): + enddate = obj.dtend.value + elif hasattr(obj, "duration"): + enddate = obj.dtstart.value + obj.duration.value + else: + enddate = obj.dtstart.value + timedelta(days=1) + + # End date for an all day event is exclusive. This fixes the case where + # an all day event has a start and end values are the same, or the event + # has a zero duration. + if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: + enddate += timedelta(days=1) + + return enddate From af851b6c2be555420d7badd82d6bf2adf5e7967c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Oct 2023 01:16:28 -0700 Subject: [PATCH 064/982] Cleanup caldav test fixtures (#102982) * Caldav test fixture cleanup * Remove a text fixture only used 3 times --- tests/components/caldav/test_calendar.py | 943 +++++++++++------------ 1 file changed, 466 insertions(+), 477 deletions(-) diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index f64cf699451..b7c9ed32244 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -1,9 +1,13 @@ """The tests for the webdav calendar component.""" +from collections.abc import Awaitable, Callable import datetime from http import HTTPStatus +from typing import Any from unittest.mock import MagicMock, Mock, patch from caldav.objects import Event +from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.const import STATE_OFF, STATE_ON @@ -11,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -DEVICE_DATA = {"name": "Private Calendar", "device_id": "Private Calendar"} +from tests.typing import ClientSessionGenerator EVENTS = [ """BEGIN:VCALENDAR @@ -288,64 +292,64 @@ CALDAV_CONFIG = { "url": "http://test.local", "custom_calendars": [], } +UTC = "UTC" +AMERICA_NEW_YORK = "America/New_York" +ASIA_BAGHDAD = "Asia/Baghdad" + +TEST_ENTITY = "calendar.example" +CALENDAR_NAME = "Example" -@pytest.fixture -def set_tz(request): - """Set the default TZ to the one requested.""" - return request.getfixturevalue(request.param) - - -@pytest.fixture -def utc(hass): - """Set the default TZ to UTC.""" - hass.config.set_time_zone("UTC") - - -@pytest.fixture -def new_york(hass): - """Set the default TZ to America/New_York.""" - hass.config.set_time_zone("America/New_York") - - -@pytest.fixture -def baghdad(hass): - """Set the default TZ to Asia/Baghdad.""" - hass.config.set_time_zone("Asia/Baghdad") +@pytest.fixture(name="tz") +def mock_tz() -> str | None: + """Fixture to specify the Home Assistant timezone to use during the test.""" + return None @pytest.fixture(autouse=True) -def mock_http(hass): +def set_tz(hass: HomeAssistant, tz: str | None) -> None: + """Fixture to set the default TZ to the one requested.""" + if tz is not None: + hass.config.set_time_zone(tz) + + +@pytest.fixture(autouse=True) +def mock_http(hass: HomeAssistant) -> None: """Mock the http component.""" hass.http = Mock() -@pytest.fixture -def mock_dav_client(): - """Mock the dav client.""" - patch_dav_client = patch( - "caldav.DAVClient", return_value=_mocked_dav_client("First", "Second") - ) - with patch_dav_client as dav_client: - yield dav_client +@pytest.fixture(name="calendar_names") +def mock_calendar_names() -> list[str]: + """Fixture to provide calendars returned by CalDAV client.""" + return ["Example"] -@pytest.fixture(name="calendar") -def mock_private_cal(): - """Mock a private calendar.""" - _calendar = _mock_calendar("Private") - calendars = [_calendar] - client = _mocked_dav_client(calendars=calendars) - patch_dav_client = patch("caldav.DAVClient", return_value=client) - with patch_dav_client: - yield _calendar +@pytest.fixture(name="calendars") +def mock_calendars(calendar_names: list[str]) -> list[Mock]: + """Fixture to provide calendars returned by CalDAV client.""" + return [_mock_calendar(name) for name in calendar_names] + + +@pytest.fixture(name="dav_client", autouse=True) +def mock_dav_client(calendars: list[Mock]) -> Mock: + """Fixture to mock the DAVClient.""" + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.return_value.calendars.return_value = ( + calendars + ) + yield mock_client @pytest.fixture -def get_api_events(hass_client): +def get_api_events( + hass_client: ClientSessionGenerator, +) -> Callable[[str], Awaitable[dict[str, Any]]]: """Fixture to return events for a specific calendar using the API.""" - async def api_call(entity_id): + async def api_call(entity_id: str) -> dict[str, Any]: client = await hass_client() response = await client.get( # The start/end times are arbitrary since they are ignored by `_mock_calendar` @@ -358,24 +362,12 @@ def get_api_events(hass_client): return api_call -def _local_datetime(hours, minutes): +def _local_datetime(hours: int, minutes: int) -> datetime.datetime: """Build a datetime object for testing in the correct timezone.""" return dt_util.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) -def _mocked_dav_client(*names, calendars=None): - """Mock requests.get invocations.""" - if calendars is None: - calendars = [_mock_calendar(name) for name in names] - principal = Mock() - principal.calendars = MagicMock(return_value=calendars) - - client = Mock() - client.principal = MagicMock(return_value=principal) - return client - - -def _mock_calendar(name, supported_components=None): +def _mock_calendar(name: str, supported_components: list[str] | None = None) -> Mock: calendar = Mock() events = [] for idx, event in enumerate(EVENTS): @@ -388,77 +380,78 @@ def _mock_calendar(name, supported_components=None): return calendar -async def test_setup_component(hass: HomeAssistant, mock_dav_client) -> None: - """Test setup component with calendars.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.first") - assert state.name == "First" - state = hass.states.get("calendar.second") - assert state.name == "Second" +@pytest.fixture(name="config") +def mock_config() -> dict[str, Any]: + """Fixture to provide calendar configuration.yaml.""" + return {} -async def test_setup_component_with_no_calendar_matching( - hass: HomeAssistant, mock_dav_client +@pytest.fixture(name="setup_platform_cb") +async def mock_setup_platform_cb( + hass: HomeAssistant, config: dict[str, Any] +) -> Callable[[], Awaitable[None]]: + """Fixture that returns a function to setup the calendar platform.""" + + async def _run() -> None: + assert await async_setup_component( + hass, "calendar", {"calendar": {**CALDAV_CONFIG, **config}} + ) + await hass.async_block_till_done() + + return _run + + +@pytest.mark.parametrize( + ("calendar_names", "config", "expected_entities"), + [ + (["First", "Second"], {}, ["calendar.first", "calendar.second"]), + ( + ["First", "Second"], + {"calendars": ["none"]}, + [], + ), + (["First", "Second"], {"calendars": ["Second"]}, ["calendar.second"]), + ( + ["First", "Second"], + { + "custom_calendars": { + "name": "HomeOffice", + "calendar": "Second", + "search": "HomeOffice", + }, + }, + ["calendar.second_homeoffice"], + ), + ], + ids=("config", "no_match", "match", "custom"), +) +async def test_setup_component_config( + hass: HomeAssistant, + config: dict[str, Any], + expected_entities: list[str], + setup_platform_cb: Callable[[], Awaitable[None]], ) -> None: """Test setup component with wrong calendar.""" - config = dict(CALDAV_CONFIG) - config["calendars"] = ["none"] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - all_calendar_states = hass.states.async_entity_ids("calendar") - assert not all_calendar_states + all_calendar_entities = hass.states.async_entity_ids("calendar") + assert all_calendar_entities == expected_entities -async def test_setup_component_with_a_calendar_match( - hass: HomeAssistant, mock_dav_client +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 45)) +async def test_ongoing_event( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: - """Test setup component with right calendar.""" - config = dict(CALDAV_CONFIG) - config["calendars"] = ["Second"] - - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - all_calendar_states = hass.states.async_entity_ids("calendar") - assert len(all_calendar_states) == 1 - state = hass.states.get("calendar.second") - assert state.name == "Second" - - -async def test_setup_component_with_one_custom_calendar( - hass: HomeAssistant, mock_dav_client -) -> None: - """Test setup component with custom calendars.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "HomeOffice", "calendar": "Second", "search": "HomeOffice"} - ] - - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - all_calendar_states = hass.states.async_entity_ids("calendar") - assert len(all_calendar_states) == 1 - state = hass.states.get("calendar.second_homeoffice") - assert state.name == "HomeOffice" - - -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 45)) -async def test_ongoing_event(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: """Test that the ongoing event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -469,20 +462,20 @@ async def test_ongoing_event(mock_now, hass: HomeAssistant, calendar, set_tz) -> } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 30)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 30)) async def test_just_ended_event( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the next ongoing event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -493,20 +486,20 @@ async def test_just_ended_event( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 00)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 00)) async def test_ongoing_event_different_tz( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the ongoing event with another timezone is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "Enjoy the sun", "all_day": False, "offset_reached": False, @@ -517,20 +510,20 @@ async def test_ongoing_event_different_tz( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(19, 10)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(19, 10)) async def test_ongoing_floating_event_returned( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that floating events without timezones work.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a floating Event", "all_day": False, "offset_reached": False, @@ -541,20 +534,20 @@ async def test_ongoing_floating_event_returned( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(8, 30)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(8, 30)) async def test_ongoing_event_with_offset( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the offset is taken into account.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is an offset event", "all_day": False, "offset_reached": True, @@ -565,23 +558,36 @@ async def test_ongoing_event_with_offset( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) -async def test_matching_filter(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: +@pytest.mark.parametrize( + ("tz", "config"), + [ + ( + UTC, + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + }, + ) + ], +) +@freeze_time(_local_datetime(12, 00)) +async def test_matching_filter( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] +) -> None: """Test that the matching event is returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -592,25 +598,37 @@ async def test_matching_filter(mock_now, hass: HomeAssistant, calendar, set_tz) } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) +@pytest.mark.parametrize( + ("tz", "config"), + [ + ( + UTC, + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": r".*rainy", + } + ] + }, + ) + ], +) +@freeze_time(_local_datetime(12, 00)) async def test_matching_filter_real_regexp( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the event matching the regexp is returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": r".*rainy"} - ] - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -621,138 +639,137 @@ async def test_matching_filter_real_regexp( } -@patch("homeassistant.util.dt.now", return_value=_local_datetime(20, 00)) +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + } + ], +) +@freeze_time(_local_datetime(20, 00)) async def test_filter_matching_past_event( - mock_now, hass: HomeAssistant, calendar + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the matching past event is not returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == "off" + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "offset_reached": False, + } -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a non-existing event", + } + ] + } + ], +) +@freeze_time(_local_datetime(12, 00)) async def test_no_result_with_filtering( - mock_now, hass: HomeAssistant, calendar + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that nothing is returned since nothing matches.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - { - "name": "Private", - "calendar": "Private", - "search": "This is a non-existing event", - } - ] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == "off" + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "offset_reached": False, + } -async def _day_event_returned(hass, calendar, config, date_time): - with patch("homeassistant.util.dt.now", return_value=date_time): - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name - assert state.state == STATE_ON - assert dict(state.attributes) == { - "friendly_name": "Private", - "message": "This is an all day event", - "all_day": True, - "offset_reached": False, - "start_time": "2017-11-27 00:00:00", - "end_time": "2017-11-28 00:00:00", - "location": "Hamburg", - "description": "What a beautiful day", - } - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_early( - hass: HomeAssistant, calendar, set_tz +@pytest.mark.parametrize( + ("tz", "target_datetime"), + [ + # Early + (UTC, datetime.datetime(2017, 11, 27, 0, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 0, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 0, 30)), + # Mid + (UTC, datetime.datetime(2017, 11, 27, 12, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 12, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 12, 30)), + # Late + (UTC, datetime.datetime(2017, 11, 27, 23, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 23, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 23, 30)), + ], +) +async def test_all_day_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, ) -> None: """Test that the event lasting the whole day is returned, if it's early in the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( + freezer.move_to(target_datetime.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + assert await async_setup_component( hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 0, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), + "calendar", + { + "calendar": { + **CALDAV_CONFIG, + "custom_calendars": [ + {"name": CALENDAR_NAME, "calendar": CALENDAR_NAME, "search": ".*"} + ], + } + }, ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_mid( - hass: HomeAssistant, calendar, set_tz -) -> None: - """Test that the event lasting the whole day is returned, if it's in the middle of the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( - hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 12, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_late( - hass: HomeAssistant, calendar, set_tz -) -> None: - """Test that the event lasting the whole day is returned, if it's late in the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( - hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 23, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(21, 45)) -async def test_event_rrule(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the future recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "message": "This is an all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day", + } + + +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(21, 45)) +async def test_event_rrule( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] +) -> None: + """Test that the future recurring event is returned.""" + await setup_platform_cb() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event", "all_day": False, "offset_reached": False, @@ -763,20 +780,20 @@ async def test_event_rrule(mock_now, hass: HomeAssistant, calendar, set_tz) -> N } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 15)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(22, 15)) async def test_event_rrule_ongoing( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the current recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event", "all_day": False, "offset_reached": False, @@ -787,20 +804,20 @@ async def test_event_rrule_ongoing( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 45)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(22, 45)) async def test_event_rrule_duration( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the future recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event with a duration", "all_day": False, "offset_reached": False, @@ -811,20 +828,20 @@ async def test_event_rrule_duration( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 15)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(23, 15)) async def test_event_rrule_duration_ongoing( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the ongoing recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event with a duration", "all_day": False, "offset_reached": False, @@ -835,20 +852,20 @@ async def test_event_rrule_duration_ongoing( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 37)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(23, 37)) async def test_event_rrule_endless( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event that never ends", "all_day": False, "offset_reached": False, @@ -859,95 +876,76 @@ async def test_event_rrule_endless( } -async def _event_rrule_all_day(hass, calendar, config, date_time): - with patch("homeassistant.util.dt.now", return_value=date_time): - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name - assert state.state == STATE_ON - assert dict(state.attributes) == { - "friendly_name": "Private", - "message": "This is a recurring all day event", - "all_day": True, - "offset_reached": False, - "start_time": "2016-12-01 00:00:00", - "end_time": "2016-12-02 00:00:00", - "location": "Hamburg", - "description": "Groundhog Day", - } - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_early(hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the recurring all day event is returned early in the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( - hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 0, 30).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), - ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_mid(hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the recurring all day event is returned in the middle of the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( - hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 17, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_late(hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the recurring all day event is returned late in the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( - hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 23, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 15)), +@pytest.mark.parametrize( + ("tz", "target_datetime"), + [ + # Early + (UTC, datetime.datetime(2016, 12, 1, 0, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 0, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 0, 30)), + # Mid + (UTC, datetime.datetime(2016, 12, 1, 17, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 17, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 17, 30)), + # Late + (UTC, datetime.datetime(2016, 12, 1, 23, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 23, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 23, 30)), + ], ) -async def test_event_rrule_hourly_on_first( - mock_now, hass: HomeAssistant, calendar, set_tz +async def test_event_rrule_all_day_early( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, ) -> None: - """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + """Test that the recurring all day event is returned early in the local day, and not on the first occurrence.""" + freezer.move_to(target_datetime.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + assert await async_setup_component( + hass, + "calendar", + { + "calendar": { + **CALDAV_CONFIG, + "custom_calendars": { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": ".*", + }, + }, + }, + ) await hass.async_block_till_done() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, + "message": "This is a recurring all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2016-12-01 00:00:00", + "end_time": "2016-12-02 00:00:00", + "location": "Hamburg", + "description": "Groundhog Day", + } + + +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 15))) +async def test_event_rrule_hourly_on_first( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] +) -> None: + """Test that the endless recurring event is returned.""" + await setup_platform_cb() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, "message": "This is an hourly recurring event", "all_day": False, "offset_reached": False, @@ -958,23 +956,20 @@ async def test_event_rrule_hourly_on_first( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 15)), -) +@pytest.mark.parametrize("tz", ["UTC"]) +@freeze_time(dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 15))) async def test_event_rrule_hourly_on_last( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is an hourly recurring event", "all_day": False, "offset_reached": False, @@ -985,77 +980,67 @@ async def test_event_rrule_hourly_on_last( } -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 45)), +@pytest.mark.parametrize( + ("target_datetime"), + [ + datetime.datetime(2015, 11, 27, 0, 45), + datetime.datetime(2015, 11, 27, 11, 45), + datetime.datetime(2015, 11, 27, 12, 15), + ], ) -async def test_event_rrule_hourly_off_first( - mock_now, hass: HomeAssistant, calendar +async def test_event_rrule_hourly( + hass: HomeAssistant, + setup_platform_cb: Callable[[], Awaitable[None]], + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + freezer.move_to(dt_util.as_local(target_datetime)) + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 45)), -) -async def test_event_rrule_hourly_off_last( - mock_now, hass: HomeAssistant, calendar +async def test_get_events( + hass: HomeAssistant, + get_api_events: Callable[[str], Awaitable[dict[str, Any]]], + setup_platform_cb: Callable[[], Awaitable[None]], + calendars: list[Mock], ) -> None: - """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private") - assert state.name == calendar.name - assert state.state == STATE_OFF - - -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 12, 15)), -) -async def test_event_rrule_hourly_ended( - mock_now, hass: HomeAssistant, calendar -) -> None: - """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private") - assert state.name == calendar.name - assert state.state == STATE_OFF - - -async def test_get_events(hass: HomeAssistant, calendar, get_api_events) -> None: """Test that all events are returned on API.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - events = await get_api_events("calendar.private") + events = await get_api_events(TEST_ENTITY) assert len(events) == 18 - assert calendar.call + assert calendars[0].call +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + } + ], +) async def test_get_events_custom_calendars( - hass: HomeAssistant, calendar, get_api_events + hass: HomeAssistant, + get_api_events: Callable[[str], Awaitable[dict[str, Any]]], + setup_platform_cb: Callable[[], Awaitable[None]], ) -> None: """Test that only searched events are returned on API.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - events = await get_api_events("calendar.private_private") + events = await get_api_events("calendar.example_example") assert events == [ { "end": {"dateTime": "2017-11-27T10:00:00-08:00"}, @@ -1070,31 +1055,34 @@ async def test_get_events_custom_calendars( ] +@pytest.mark.parametrize( + ("calendars"), + [ + [ + _mock_calendar("Calendar 1", supported_components=["VEVENT"]), + _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), + _mock_calendar("Calendar 3", supported_components=["VTODO"]), + # Fallback to allow when no components are supported to be conservative + _mock_calendar("Calendar 4", supported_components=[]), + ] + ], +) async def test_calendar_components( hass: HomeAssistant, + dav_client: Mock, ) -> None: """Test that only calendars that support events are created.""" - calendars = [ - _mock_calendar("Calendar 1", supported_components=["VEVENT"]), - _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), - _mock_calendar("Calendar 3", supported_components=["VTODO"]), - # Fallback to allow when no components are supported to be conservative - _mock_calendar("Calendar 4", supported_components=[]), - ] - with patch( - "homeassistant.components.caldav.calendar.caldav.DAVClient", - return_value=_mocked_dav_client(calendars=calendars), - ): - assert await async_setup_component( - hass, "calendar", {"calendar": CALDAV_CONFIG} - ) - await hass.async_block_till_done() + + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() state = hass.states.get("calendar.calendar_1") + assert state assert state.name == "Calendar 1" assert state.state == STATE_OFF state = hass.states.get("calendar.calendar_2") + assert state assert state.name == "Calendar 2" assert state.state == STATE_OFF @@ -1103,5 +1091,6 @@ async def test_calendar_components( assert not state state = hass.states.get("calendar.calendar_4") + assert state assert state.name == "Calendar 4" assert state.state == STATE_OFF From 9cc7012d3217e9b3effbc7a9379526727cb64860 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 29 Oct 2023 09:17:57 +0100 Subject: [PATCH 065/982] Correct total state_class of huisbaasje sensors (#102945) * Change all cumulative-interval sensors to TOTAL --- homeassistant/components/huisbaasje/sensor.py | 16 ++++----- tests/components/huisbaasje/test_sensor.py | 34 +++++-------------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 6e3f5eaee33..b82b2b34a4b 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -146,7 +146,7 @@ SENSORS_INFO = [ translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_DAY, precision=1, @@ -156,7 +156,7 @@ SENSORS_INFO = [ translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_WEEK, precision=1, @@ -166,7 +166,7 @@ SENSORS_INFO = [ translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_MONTH, precision=1, @@ -176,7 +176,7 @@ SENSORS_INFO = [ translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_YEAR, precision=1, @@ -197,7 +197,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -207,7 +207,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -217,7 +217,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -227,7 +227,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 484dc8bac48..3f0bdae8e53 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -222,10 +222,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY ) assert energy_today.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" - assert ( - energy_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert energy_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( energy_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR @@ -239,8 +236,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_week.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -255,8 +251,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_month.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -271,8 +266,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_year.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -295,10 +289,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_today.state == "1.1" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -308,10 +299,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_week.state == "5.6" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -321,10 +309,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_month.state == "39.1" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -334,10 +319,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_year.state == "116.7" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS From 19f1d1400000fb4844c90362b9dd3544af6b1289 Mon Sep 17 00:00:00 2001 From: Tom Puttemans Date: Sun, 29 Oct 2023 10:23:24 +0100 Subject: [PATCH 066/982] DSMR Gas currently delivered device state class conflict (#102991) Fixes #102985 --- homeassistant/components/dsmr_reader/definitions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index d89e30311e9..f12b2ad72bc 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -210,7 +210,6 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", translation_key="current_gas_usage", - device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), From 2616794e1dce27858caeb6f1019465d6ccb0ba45 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 Oct 2023 10:43:57 +0100 Subject: [PATCH 067/982] Fix proximity entity id (#102992) * fix proximity entity id * extend test to cover entity id --- homeassistant/components/proximity/__init__.py | 2 +- tests/components/proximity/test_init.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 07b5f931f79..23a8fc3bf64 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -88,7 +88,7 @@ def async_setup_proximity_component( proximity_zone, unit_of_measurement, ) - proximity.entity_id = f"{DOMAIN}.{proximity_zone}" + proximity.entity_id = f"{DOMAIN}.{zone_friendly_name}" proximity.async_write_ha_state() diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 0ec8765e604..cd96d0d7b81 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -13,6 +13,11 @@ async def test_proximities(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, + "home_test2": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1", "device_tracker.test2"], + "tolerance": "1", + }, "work": { "devices": ["device_tracker.test1"], "tolerance": "1", @@ -23,7 +28,7 @@ async def test_proximities(hass: HomeAssistant) -> None: assert await async_setup_component(hass, DOMAIN, config) - proximities = ["home", "work"] + proximities = ["home", "home_test2", "work"] for prox in proximities: state = hass.states.get(f"proximity.{prox}") From 8a87ea550671f29fcb2c463f3266de76da123f76 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 29 Oct 2023 13:28:35 +0100 Subject: [PATCH 068/982] Harden evohome against failures to retrieve high-precision temps (#102989) fix hass-logger-period --- homeassistant/components/evohome/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 2aa0cd42fe1..4b79ef3df1b 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -487,6 +487,18 @@ class EvoBroker: ) self.temps = None # these are now stale, will fall back to v2 temps + except KeyError as err: + _LOGGER.warning( + ( + "Unable to obtain high-precision temperatures. " + "It appears the JSON schema is not as expected, " + "so the high-precision feature will be disabled until next restart." + "Message is: %s" + ), + err, + ) + self.client_v1 = self.temps = None + else: if ( str(self.client_v1.location_id) @@ -495,7 +507,7 @@ class EvoBroker: _LOGGER.warning( "The v2 API's configured location doesn't match " "the v1 API's default location (there is more than one location), " - "so the high-precision feature will be disabled" + "so the high-precision feature will be disabled until next restart" ) self.client_v1 = self.temps = None else: From 59f238b9a774cd13f647d75546bc3d483ef19c18 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 29 Oct 2023 14:47:24 +0100 Subject: [PATCH 069/982] Clean up two year old entity migration from Tuya (#103003) --- homeassistant/components/tuya/__init__.py | 85 +---------------------- 1 file changed, 1 insertion(+), 84 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 509e7e17013..89b49a639cd 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -14,12 +14,10 @@ from tuya_iot import ( TuyaOpenMQ, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( @@ -37,7 +35,6 @@ from .const import ( PLATFORMS, TUYA_DISCOVERY_NEW, TUYA_HA_SIGNAL_UPDATE_ENTITY, - DPCode, ) @@ -108,9 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.async_add_executor_job(home_manager.update_device_cache) await cleanup_device_registry(hass, device_manager) - # Migrate old unique_ids to the new format - async_migrate_entities_unique_ids(hass, entry, device_manager) - # Register known device IDs device_registry = dr.async_get(hass) for device in device_manager.device_map.values(): @@ -139,83 +133,6 @@ async def cleanup_device_registry( break -@callback -def async_migrate_entities_unique_ids( - hass: HomeAssistant, config_entry: ConfigEntry, device_manager: TuyaDeviceManager -) -> None: - """Migrate unique_ids in the entity registry to the new format.""" - entity_registry = er.async_get(hass) - registry_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - light_entries = { - entry.unique_id: entry - for entry in registry_entries - if entry.domain == LIGHT_DOMAIN - } - switch_entries = { - entry.unique_id: entry - for entry in registry_entries - if entry.domain == SWITCH_DOMAIN - } - - for device in device_manager.device_map.values(): - # Old lights where in `tuya.{device_id}` format, now the DPCode is added. - # - # If the device is a previously supported light category and still has - # the old format for the unique ID, migrate it to the new format. - # - # Previously only devices providing the SWITCH_LED DPCode were supported, - # thus this can be added to those existing IDs. - # - # `tuya.{device_id}` -> `tuya.{device_id}{SWITCH_LED}` - if ( - device.category in ("dc", "dd", "dj", "fs", "fwl", "jsq", "xdd", "xxj") - and (entry := light_entries.get(f"tuya.{device.id}")) - and f"tuya.{device.id}{DPCode.SWITCH_LED}" not in light_entries - ): - entity_registry.async_update_entity( - entry.entity_id, new_unique_id=f"tuya.{device.id}{DPCode.SWITCH_LED}" - ) - - # Old switches has different formats for the unique ID, but is mappable. - # - # If the device is a previously supported switch category and still has - # the old format for the unique ID, migrate it to the new format. - # - # `tuya.{device_id}` -> `tuya.{device_id}{SWITCH}` - # `tuya.{device_id}_1` -> `tuya.{device_id}{SWITCH_1}` - # ... - # `tuya.{device_id}_6` -> `tuya.{device_id}{SWITCH_6}` - # `tuya.{device_id}_usb1` -> `tuya.{device_id}{SWITCH_USB1}` - # ... - # `tuya.{device_id}_usb6` -> `tuya.{device_id}{SWITCH_USB6}` - # - # In all other cases, the unique ID is not changed. - if device.category in ("bh", "cwysj", "cz", "dlq", "kg", "kj", "pc", "xxj"): - for postfix, dpcode in ( - ("", DPCode.SWITCH), - ("_1", DPCode.SWITCH_1), - ("_2", DPCode.SWITCH_2), - ("_3", DPCode.SWITCH_3), - ("_4", DPCode.SWITCH_4), - ("_5", DPCode.SWITCH_5), - ("_6", DPCode.SWITCH_6), - ("_usb1", DPCode.SWITCH_USB1), - ("_usb2", DPCode.SWITCH_USB2), - ("_usb3", DPCode.SWITCH_USB3), - ("_usb4", DPCode.SWITCH_USB4), - ("_usb5", DPCode.SWITCH_USB5), - ("_usb6", DPCode.SWITCH_USB6), - ): - if ( - entry := switch_entries.get(f"tuya.{device.id}{postfix}") - ) and f"tuya.{device.id}{dpcode}" not in switch_entries: - entity_registry.async_update_entity( - entry.entity_id, new_unique_id=f"tuya.{device.id}{dpcode}" - ) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the Tuya platforms.""" unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) From 94e192db12723e2fd7d9ecbb5a9807ff80ea15f1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 Oct 2023 12:44:15 -0400 Subject: [PATCH 070/982] Fix zwave_js siren name (#103016) * Fix zwave_js.siren name * Fix test --- homeassistant/components/zwave_js/entity.py | 2 +- homeassistant/components/zwave_js/siren.py | 2 ++ tests/components/zwave_js/test_siren.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 0b9c68e9664..e7e110e7db6 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -206,7 +206,7 @@ class ZWaveBaseEntity(Entity): ): name += f" ({primary_value.endpoint})" - return name + return name.strip() @property def available(self) -> bool: diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 6de6b0f4e45..7df88f7dca4 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -72,6 +72,8 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): if self._attr_available_tones: self._attr_supported_features |= SirenEntityFeature.TONES + self._attr_name = self.generate_name(include_value_name=True) + @property def is_on(self) -> bool | None: """Return whether device is on.""" diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 210339e22d7..6df5881107a 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -9,7 +9,7 @@ from homeassistant.components.siren import ( from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -SIREN_ENTITY = "siren.indoor_siren_6_2" +SIREN_ENTITY = "siren.indoor_siren_6_play_tone_2" TONE_ID_VALUE_ID = { "endpoint": 2, From b7667d44fdaa8201bd70939410ba293e0561d226 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 29 Oct 2023 18:23:48 +0100 Subject: [PATCH 071/982] Use built in config entry from coordinator in HomeWizard (#102959) --- homeassistant/components/homewizard/__init__.py | 3 +-- homeassistant/components/homewizard/button.py | 10 +++------- .../components/homewizard/coordinator.py | 17 +++++++++++------ homeassistant/components/homewizard/entity.py | 2 +- homeassistant/components/homewizard/helpers.py | 4 +++- homeassistant/components/homewizard/number.py | 7 ++++--- homeassistant/components/homewizard/sensor.py | 10 +++++----- homeassistant/components/homewizard/switch.py | 9 ++------- 8 files changed, 30 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 01705d66f50..036f6c077da 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,6 +1,5 @@ """The Homewizard integration.""" from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -10,7 +9,7 @@ from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homewizard from a config entry.""" - coordinator = Coordinator(hass, entry, entry.data[CONF_IP_ADDRESS]) + coordinator = Coordinator(hass) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 19ffb1d6042..8a6936ee1c8 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -18,7 +18,7 @@ async def async_setup_entry( """Set up the Identify button.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] if coordinator.supports_identify(): - async_add_entities([HomeWizardIdentifyButton(coordinator, entry)]) + async_add_entities([HomeWizardIdentifyButton(coordinator)]) class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): @@ -27,14 +27,10 @@ class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.IDENTIFY - def __init__( - self, - coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, - ) -> None: + def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None: """Initialize button.""" super().__init__(coordinator) - self._attr_unique_id = f"{entry.unique_id}_identify" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_identify" @homewizard_exception_handler async def async_press(self) -> None: diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index fb89989b2a5..e38b1d54471 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -9,6 +9,7 @@ from homewizard_energy.errors import DisabledError, RequestError, UnsupportedErr from homewizard_energy.models import Device from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,16 +27,18 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] _unsupported_error: bool = False + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, - host: str, ) -> None: """Initialize update coordinator.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) - self.entry = entry - self.api = HomeWizardEnergy(host, clientsession=async_get_clientsession(hass)) + self.api = HomeWizardEnergy( + self.config_entry.data[CONF_IP_ADDRESS], + clientsession=async_get_clientsession(hass), + ) async def _async_update_data(self) -> DeviceResponseEntry: """Fetch all device and sensor data from api.""" @@ -58,7 +61,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] self._unsupported_error = True _LOGGER.warning( "%s is running an outdated firmware version (%s). Contact HomeWizard support to update your device", - self.entry.title, + self.config_entry.title, ex, ) @@ -71,7 +74,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] # Do not reload when performing first refresh if self.data is not None: - await self.hass.config_entries.async_reload(self.entry.entry_id) + await self.hass.config_entries.async_reload( + self.config_entry.entry_id + ) raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 61bf20dbbc4..2090cc363ba 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -16,7 +16,7 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None: """Initialize the HomeWizard entity.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self._attr_device_info = DeviceInfo( manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index d2d1b7c0119..3f7fc064931 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -32,7 +32,9 @@ def homewizard_exception_handler( except RequestError as ex: raise HomeAssistantError from ex except DisabledError as ex: - await self.hass.config_entries.async_reload(self.coordinator.entry.entry_id) + await self.hass.config_entries.async_reload( + self.coordinator.config_entry.entry_id + ) raise HomeAssistantError from ex return handler diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 07f6bb9b55f..58e0b02a06c 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -21,7 +21,7 @@ async def async_setup_entry( """Set up numbers for device.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] if coordinator.supports_state(): - async_add_entities([HWEnergyNumberEntity(coordinator, entry)]) + async_add_entities([HWEnergyNumberEntity(coordinator)]) class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @@ -35,11 +35,12 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, ) -> None: """Initialize the control number.""" super().__init__(coordinator) - self._attr_unique_id = f"{entry.unique_id}_status_light_brightness" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_status_light_brightness" + ) @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index d8cc72ce45e..a342e11bea0 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -26,6 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -39,7 +40,7 @@ class HomeWizardEntityDescriptionMixin: """Mixin values for HomeWizard entities.""" has_fn: Callable[[Data], bool] - value_fn: Callable[[Data], float | int | str | None] + value_fn: Callable[[Data], StateType] @dataclass @@ -433,7 +434,7 @@ async def async_setup_entry( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - HomeWizardSensorEntity(coordinator, entry, description) + HomeWizardSensorEntity(coordinator, description) for description in SENSORS if description.has_fn(coordinator.data.data) ) @@ -447,18 +448,17 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, description: HomeWizardSensorEntityDescription, ) -> None: """Initialize Sensor Domain.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" if not description.enabled_fn(self.coordinator.data.data): self._attr_entity_registry_enabled_default = False @property - def native_value(self) -> float | int | str | None: + def native_value(self) -> StateType: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data.data) diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index cddcabc841e..ed59963aa41 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -86,11 +86,7 @@ async def async_setup_entry( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - HomeWizardSwitchEntity( - coordinator=coordinator, - description=description, - entry=entry, - ) + HomeWizardSwitchEntity(coordinator, description) for description in SWITCHES if description.create_fn(coordinator) ) @@ -105,12 +101,11 @@ class HomeWizardSwitchEntity(HomeWizardEntity, SwitchEntity): self, coordinator: HWEnergyDeviceUpdateCoordinator, description: HomeWizardSwitchEntityDescription, - entry: ConfigEntry, ) -> None: """Initialize the switch.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" @property def icon(self) -> str | None: From 46ecf0d4bd4350de12f80ef18e00cb16a6e2e444 Mon Sep 17 00:00:00 2001 From: kpine Date: Sun, 29 Oct 2023 11:15:19 -0700 Subject: [PATCH 072/982] Revert "Fix temperature setting for multi-setpoint z-wave device (#102395)" (#103022) This reverts commit 2d6dc2bcccff7518366655a67947d73506fc1e50. --- homeassistant/components/zwave_js/climate.py | 8 +- tests/components/zwave_js/conftest.py | 14 - .../climate_intermatic_pe653_state.json | 4508 ----------------- tests/components/zwave_js/test_climate.py | 193 - 4 files changed, 3 insertions(+), 4720 deletions(-) delete mode 100644 tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 28084eecfa6..d511a030fb1 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -259,11 +259,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): def _current_mode_setpoint_enums(self) -> list[ThermostatSetpointType]: """Return the list of enums that are relevant to the current thermostat mode.""" if self._current_mode is None or self._current_mode.value is None: - # Thermostat with no support for setting a mode is just a setpoint - if self.info.primary_value.property_key is None: - return [] - return [ThermostatSetpointType(int(self.info.primary_value.property_key))] - + # Thermostat(valve) with no support for setting a mode + # is considered heating-only + return [ThermostatSetpointType.HEATING] return THERMOSTAT_MODE_SETPOINT_MAP.get(int(self._current_mode.value), []) @property diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 534f2fd2457..5a424b38c5b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -662,12 +662,6 @@ def logic_group_zdb5100_state_fixture(): return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) -@pytest.fixture(name="climate_intermatic_pe653_state", scope="session") -def climate_intermatic_pe653_state_fixture(): - """Load Intermatic PE653 Pool Control node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_intermatic_pe653_state.json")) - - @pytest.fixture(name="central_scene_node_state", scope="session") def central_scene_node_state_fixture(): """Load node with Central Scene CC node state fixture data.""" @@ -1312,14 +1306,6 @@ def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): return node -@pytest.fixture(name="climate_intermatic_pe653") -def climate_intermatic_pe653_fixture(client, climate_intermatic_pe653_state): - """Mock an Intermatic PE653 node.""" - node = Node(client, copy.deepcopy(climate_intermatic_pe653_state)) - client.driver.controller.nodes[node.node_id] = node - return node - - @pytest.fixture(name="central_scene_node") def central_scene_node_fixture(client, central_scene_node_state): """Mock a node with the Central Scene CC.""" diff --git a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json b/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json deleted file mode 100644 index a5e86b9c013..00000000000 --- a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json +++ /dev/null @@ -1,4508 +0,0 @@ -{ - "nodeId": 19, - "index": 0, - "status": 4, - "ready": true, - "isListening": true, - "isRouting": true, - "isSecure": false, - "manufacturerId": 5, - "productId": 1619, - "productType": 20549, - "firmwareVersion": "3.9", - "deviceConfig": { - "filename": "/data/db/devices/0x0005/pe653.json", - "isEmbedded": true, - "manufacturer": "Intermatic", - "manufacturerId": 5, - "label": "PE653", - "description": "Pool Control", - "devices": [ - { - "productType": 20549, - "productId": 1619 - } - ], - "firmwareVersion": { - "min": "0.0", - "max": "255.255" - }, - "preferred": false, - "associations": {}, - "paramInformation": { - "_map": {} - }, - "compat": { - "addCCs": {}, - "overrideQueries": { - "overrides": {} - } - } - }, - "label": "PE653", - "endpointCountIsDynamic": false, - "endpointsHaveIdenticalCapabilities": false, - "individualEndpointCount": 39, - "aggregatedEndpointCount": 0, - "interviewAttempts": 1, - "endpoints": [ - { - "nodeId": 19, - "index": 0, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 96, - "name": "Multi Channel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 2, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 3, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 4, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 5, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 6, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 7, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 8, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 9, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 10, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 11, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 12, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 13, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 14, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 15, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 16, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 17, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 18, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 19, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 20, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 21, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 22, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 23, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 24, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 25, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 26, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 27, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 28, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 29, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 30, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 31, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 32, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 33, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 34, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 35, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 36, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 37, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 38, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 39, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - } - ], - "values": [ - { - "endpoint": 0, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 7, - "propertyName": "setpoint", - "propertyKeyName": "Furnace", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Furnace)", - "ccSpecific": { - "setpointType": 7 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 60 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 2, - "propertyName": "Installed Pump Type", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Installed Pump Type", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "One Speed", - "1": "Two Speed" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Installed Pump Type" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 1, - "propertyName": "Booster (Cleaner) Pump Installed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Booster (Cleaner) Pump Installed", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Installed" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 65280, - "propertyName": "Booster (Cleaner) Pump Operation Mode", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Set the filter pump mode to use when the booster (cleaner) pump is running.", - "label": "Booster (Cleaner) Pump Operation Mode", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disable", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Operation Mode", - "info": "Set the filter pump mode to use when the booster (cleaner) pump is running." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 65280, - "propertyName": "Heater Cooldown Period", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Heater Cooldown Period", - "default": -1, - "min": -1, - "max": 15, - "states": { - "0": "Heater installed with no cooldown", - "-1": "No heater installed" - }, - "unit": "minutes", - "valueSize": 2, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Heater Cooldown Period" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 1, - "propertyName": "Heater Safety Setting", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Prevent the heater from turning on while the pump is off.", - "label": "Heater Safety Setting", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Heater Safety Setting", - "info": "Prevent the heater from turning on while the pump is off." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 4278190080, - "propertyName": "Water Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Water Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Water Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 16711680, - "propertyName": "Air/Freeze Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Air/Freeze Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Air/Freeze Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 65280, - "propertyName": "Solar Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Solar Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Solar Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 22, - "propertyName": "Pool/Spa Configuration", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Pool/Spa Configuration", - "default": 0, - "min": 0, - "max": 2, - "states": { - "0": "Pool", - "1": "Spa", - "2": "Both" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Pool/Spa Configuration" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 23, - "propertyName": "Spa Mode Pump Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration.", - "label": "Spa Mode Pump Speed", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disabled", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Spa Mode Pump Speed", - "info": "Requires pool/spa configuration." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 32, - "propertyName": "Variable Speed Pump - Speed 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 1", - "default": 750, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 1", - "info": "Requires connected variable speed pump." - }, - "value": 1400 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 33, - "propertyName": "Variable Speed Pump - Speed 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 2", - "default": 1500, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 2", - "info": "Requires connected variable speed pump." - }, - "value": 1700 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 34, - "propertyName": "Variable Speed Pump - Speed 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 3", - "default": 2350, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 3", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 35, - "propertyName": "Variable Speed Pump - Speed 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 4", - "default": 3110, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 4", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 49, - "propertyName": "Variable Speed Pump - Max Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Max Speed", - "default": 3450, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Max Speed", - "info": "Requires connected variable speed pump." - }, - "value": 3000 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 4278190080, - "propertyName": "Freeze Protection: Temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Temperature", - "default": 0, - "min": 0, - "max": 44, - "states": { - "0": "Disabled", - "40": "40 °F", - "41": "41 °F", - "42": "42 °F", - "43": "43 °F", - "44": "44 °F" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Temperature" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65536, - "propertyName": "Freeze Protection: Turn On Circuit 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 1", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 1" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 131072, - "propertyName": "Freeze Protection: Turn On Circuit 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 2", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 2" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 262144, - "propertyName": "Freeze Protection: Turn On Circuit 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 3", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 3" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 524288, - "propertyName": "Freeze Protection: Turn On Circuit 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 4", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 4" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 1048576, - "propertyName": "Freeze Protection: Turn On Circuit 5", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 5", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 5" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65280, - "propertyName": "Freeze Protection: Turn On VSP Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires variable speed pump and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On VSP Speed", - "default": 0, - "min": 0, - "max": 5, - "states": { - "0": "None", - "2": "VSP Speed 1", - "3": "VSP Speed 2", - "4": "VSP Speed 3", - "5": "VSP Speed 4" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On VSP Speed", - "info": "Requires variable speed pump and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 128, - "propertyName": "Freeze Protection: Turn On Heater", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires heater and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On Heater", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Heater", - "info": "Requires heater and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 127, - "propertyName": "Freeze Protection: Pool/Spa Cycle Time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration and connected air/freeze sensor.", - "label": "Freeze Protection: Pool/Spa Cycle Time", - "default": 0, - "min": 0, - "max": 30, - "unit": "minutes", - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Freeze Protection: Pool/Spa Cycle Time", - "info": "Requires pool/spa configuration and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 4, - "propertyName": "Circuit 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable.", - "label": "Circuit 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 1", - "info": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable." - }, - "value": 1979884035 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 5, - "propertyName": "Circuit 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 6, - "propertyName": "Circuit 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 7, - "propertyName": "Circuit 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 8, - "propertyName": "Circuit 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 9, - "propertyName": "Circuit 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 10, - "propertyName": "Circuit 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 11, - "propertyName": "Circuit 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 12, - "propertyName": "Circuit 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 13, - "propertyName": "Circuit 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 14, - "propertyName": "Circuit 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 15, - "propertyName": "Circuit 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 16, - "propertyName": "Circuit 5 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 17, - "propertyName": "Circuit 5 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 18, - "propertyName": "Circuit 5 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 19, - "propertyName": "Pool/Spa Mode Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 20, - "propertyName": "Pool/Spa Mode Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 21, - "propertyName": "Pool/Spa Mode Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 36, - "propertyName": "Variable Speed Pump Speed 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 37, - "propertyName": "Variable Speed Pump Speed 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 38, - "propertyName": "Variable Speed Pump Speed 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 39, - "propertyName": "Variable Speed Pump Speed 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 1476575235 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 40, - "propertyName": "Variable Speed Pump Speed 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 41, - "propertyName": "Variable Speed Pump Speed 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 42, - "propertyName": "Variable Speed Pump Speed 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 43, - "propertyName": "Variable Speed Pump Speed 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 44, - "propertyName": "Variable Speed Pump Speed 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 45, - "propertyName": "Variable Speed Pump Speed 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 46, - "propertyName": "Variable Speed Pump Speed 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 47, - "propertyName": "Variable Speed Pump Speed 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "manufacturerId", - "propertyName": "manufacturerId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Manufacturer ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 5 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productType", - "propertyName": "productType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product type", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 20549 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productId", - "propertyName": "productId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 1619 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "libraryType", - "propertyName": "libraryType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Library type", - "states": { - "0": "Unknown", - "1": "Static Controller", - "2": "Controller", - "3": "Enhanced Slave", - "4": "Slave", - "5": "Installer", - "6": "Routing Slave", - "7": "Bridge Controller", - "8": "Device under Test", - "9": "N/A", - "10": "AV Remote", - "11": "AV Device" - }, - "stateful": true, - "secret": false - }, - "value": 6 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "protocolVersion", - "propertyName": "protocolVersion", - "ccVersion": 1, - "metadata": { - "type": "string", - "readable": true, - "writeable": false, - "label": "Z-Wave protocol version", - "stateful": true, - "secret": false - }, - "value": "2.78" - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "firmwareVersions", - "propertyName": "firmwareVersions", - "ccVersion": 1, - "metadata": { - "type": "string[]", - "readable": true, - "writeable": false, - "label": "Z-Wave chip firmware versions", - "stateful": true, - "secret": false - }, - "value": ["3.9"] - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 48, - "commandClassName": "Binary Sensor", - "property": "Any", - "propertyName": "Any", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Sensor state (Any)", - "ccSpecific": { - "sensorType": 255 - }, - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 81, - "nodeId": 19 - }, - { - "endpoint": 1, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 1, - "propertyName": "setpoint", - "propertyKeyName": "Heating", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Heating)", - "ccSpecific": { - "setpointType": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 39 - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 2, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 84, - "nodeId": 19 - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 86, - "nodeId": 19 - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 80, - "nodeId": 19 - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 5, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 83, - "nodeId": 19 - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - } - ], - "isFrequentListening": false, - "maxDataRate": 40000, - "supportedDataRates": [40000], - "protocolVersion": 2, - "supportsBeaming": true, - "supportsSecurity": false, - "nodeType": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "interviewStage": "Complete", - "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0005:0x5045:0x0653:3.9", - "highestSecurityClass": -1, - "isControllerNode": false, - "keepAwake": false -} diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index cdc1e9959a7..e9040dfd397 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -792,196 +792,3 @@ async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" in caplog.text ) - - -async def test_multi_setpoint_thermostat( - hass: HomeAssistant, client, climate_intermatic_pe653, integration -) -> None: - """Test a thermostat with multiple setpoints.""" - node = climate_intermatic_pe653 - - heating_entity_id = "climate.pool_control_2" - heating = hass.states.get(heating_entity_id) - assert heating - assert heating.state == HVACMode.HEAT - assert heating.attributes[ATTR_TEMPERATURE] == 3.9 - assert heating.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - heating.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - furnace_entity_id = "climate.pool_control" - furnace = hass.states.get(furnace_entity_id) - assert furnace - assert furnace.state == HVACMode.HEAT - assert furnace.attributes[ATTR_TEMPERATURE] == 15.6 - assert furnace.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - furnace.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - client.async_send_command_no_wait.reset_mock() - - # Test setting temperature of heating setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_TEMPERATURE: 20.0, - }, - blocking=True, - ) - - # Test setting temperature of furnace setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_TEMPERATURE: 2.0, - }, - blocking=True, - ) - - # Test setting illegal mode raises an error - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 1, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 1, - } - assert args["value"] == 68.0 - - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 0, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 7, - } - assert args["value"] == 35.6 - - client.async_send_command.reset_mock() - - # Test heating setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 1, - "property": "setpoint", - "propertyKey": 1, - "propertyKeyName": "Heating", - "propertyName": "setpoint", - "newValue": 23, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - # furnace not changed - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 15.6 - - client.async_send_command.reset_mock() - - # Test furnace setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 0, - "property": "setpoint", - "propertyKey": 7, - "propertyKeyName": "Furnace", - "propertyName": "setpoint", - "newValue": 68, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - # heating not changed - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 20 - - client.async_send_command.reset_mock() From b323295aa15ff6ac81e46b213a2f22440f0460de Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 29 Oct 2023 19:18:31 +0100 Subject: [PATCH 073/982] Clean up old config entry migration from Tuya (#103026) --- homeassistant/components/tuya/__init__.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 89b49a639cd..276d21f3821 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -28,7 +28,6 @@ from .const import ( CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, - CONF_PROJECT_TYPE, CONF_USERNAME, DOMAIN, LOGGER, @@ -50,13 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" hass.data.setdefault(DOMAIN, {}) - # Project type has been renamed to auth type in the upstream Tuya IoT SDK. - # This migrates existing config entries to reflect that name change. - if CONF_PROJECT_TYPE in entry.data: - data = {**entry.data, CONF_AUTH_TYPE: entry.data[CONF_PROJECT_TYPE]} - data.pop(CONF_PROJECT_TYPE) - hass.config_entries.async_update_entry(entry, data=data) - auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) api = TuyaOpenAPI( endpoint=entry.data[CONF_ENDPOINT], From d75f1b2b3ea5efcb6de2ba6fa5a3108b9874af5d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Oct 2023 14:26:10 -0700 Subject: [PATCH 074/982] Fix bug in fitbit credential import for expired tokens (#103024) * Fix bug in fitbit credential import on token refresh * Use stable test ids * Update homeassistant/components/fitbit/sensor.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/fitbit/sensor.py | 11 +++++++---- tests/components/fitbit/conftest.py | 5 +++-- tests/components/fitbit/test_config_flow.py | 18 +++++++++++++----- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 4885c9fa16d..d0d939ce67e 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -581,7 +581,9 @@ async def async_setup_platform( refresh_cb=lambda x: None, ) try: - await hass.async_add_executor_job(authd_client.client.refresh_token) + updated_token = await hass.async_add_executor_job( + authd_client.client.refresh_token + ) except OAuth2Error as err: _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) translation_key = "deprecated_yaml_import_issue_cannot_connect" @@ -599,9 +601,10 @@ async def async_setup_platform( data={ "auth_implementation": DOMAIN, CONF_TOKEN: { - ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], - "expires_at": config_file[ATTR_LAST_SAVED_AT], + ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN], + "expires_at": updated_token["expires_at"], + "scope": " ".join(updated_token.get("scope", [])), }, CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 155e5499543..682fb0edd3b 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -41,10 +41,11 @@ TIMESERIES_API_URL_FORMAT = ( # These constants differ from values in the config entry or fitbit.conf SERVER_ACCESS_TOKEN = { - "refresh_token": "server-access-token", - "access_token": "server-refresh-token", + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", "type": "Bearer", "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), } diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index 152439ec19a..cf2d5d17f22 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus +import time from typing import Any from unittest.mock import patch @@ -16,9 +17,7 @@ from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir from .conftest import ( CLIENT_ID, - FAKE_ACCESS_TOKEN, FAKE_AUTH_IMPL, - FAKE_REFRESH_TOKEN, PROFILE_API_URL, PROFILE_USER_ID, SERVER_ACCESS_TOKEN, @@ -204,6 +203,11 @@ async def test_config_entry_already_exists( assert result.get("reason") == "already_configured" +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() + 86400, time.time() - 86400], + ids=("token_active", "token_expired"), +) async def test_import_fitbit_config( hass: HomeAssistant, fitbit_config_setup: None, @@ -235,16 +239,20 @@ async def test_import_fitbit_config( assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) + # Verify imported values from fitbit.conf and configuration.yaml and + # that the token is updated. assert "token" in data + expires_at = data["token"]["expires_at"] + assert expires_at > time.time() del data["token"]["expires_at"] - # Verify imported values from fitbit.conf and configuration.yaml assert dict(config_entry.data) == { "auth_implementation": DOMAIN, "clock_format": "24H", "monitored_resources": ["activities/steps"], "token": { - "access_token": FAKE_ACCESS_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": "server-access-token", + "refresh_token": "server-refresh-token", + "scope": "activity heartrate nutrition profile settings sleep weight", }, "unit_system": "default", } From a373f5eac50f3475a3bcb20a91f9224619332967 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Oct 2023 15:48:01 -0700 Subject: [PATCH 075/982] Bump google-nest-sdm to 3.0.3 (#103035) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index bf24fc4a4e9..89244642207 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==3.0.2"] + "requirements": ["google-nest-sdm==3.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32058cfac31..9c5e1fd6d0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -910,7 +910,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20085dd9f19..a19fc6f5156 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -726,7 +726,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 From d6a0f9b5a004d8b8ca865204db1d7b37b6d219fe Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 30 Oct 2023 06:02:03 +0100 Subject: [PATCH 076/982] Give mqtt test more time to process subscriptions (#103006) --- tests/components/mqtt/test_discovery.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 863a79fce70..ed01b70e660 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1523,18 +1523,20 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( return self.async_abort(reason="already_configured") with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): - await asyncio.sleep(0.1) + await asyncio.sleep(0) assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") - await asyncio.sleep(0.1) + await asyncio.sleep(0) + await hass.async_block_till_done() await hass.async_block_till_done() mqtt_client_mock.unsubscribe.assert_called_once_with(["comp/discovery/#"]) mqtt_client_mock.unsubscribe.reset_mock() async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") - await asyncio.sleep(0.1) + await asyncio.sleep(0) + await hass.async_block_till_done() await hass.async_block_till_done() assert not mqtt_client_mock.unsubscribe.called From 89d7c33e31ee02a0314d594e60490b228d202e64 Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Mon, 30 Oct 2023 20:56:50 +1300 Subject: [PATCH 077/982] Bump starlink-grpc-core to 1.1.3 (#103043) --- homeassistant/components/starlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index c719afa968d..b8733dd2435 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["starlink-grpc-core==1.1.2"] + "requirements": ["starlink-grpc-core==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c5e1fd6d0f..b6bf9982862 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2488,7 +2488,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a19fc6f5156..3531966afcd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1851,7 +1851,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 From 036918734036eab1f183f11984352f437d2eeb42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 09:17:01 +0100 Subject: [PATCH 078/982] Bump github/codeql-action from 2.22.4 to 2.22.5 (#103045) 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 da7021e9df3..ccd2d3c1678 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.4 + uses: github/codeql-action/init@v2.22.5 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.4 + uses: github/codeql-action/analyze@v2.22.5 with: category: "/language:python" From 8fde275662a7c6418e79a9ec8ac3e0ba858fd1ed Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Mon, 30 Oct 2023 21:40:15 +1300 Subject: [PATCH 079/982] Rename Starlink Idle to Sleep (#103048) --- homeassistant/components/starlink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index 0ec85c68956..bc6807e8ba7 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -26,7 +26,7 @@ "name": "Heating" }, "power_save_idle": { - "name": "[%key:common::state::idle%]" + "name": "Sleep" }, "mast_near_vertical": { "name": "Mast near vertical" From 71ecb39dc52da75edf1395318a807c985d020a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Janu=C3=A1rio?= Date: Mon, 30 Oct 2023 08:41:53 +0000 Subject: [PATCH 080/982] Add additional sensors to ecoforest integration (#102734) --- .../components/ecoforest/manifest.json | 2 +- homeassistant/components/ecoforest/sensor.py | 60 ++++++++++++++++++- .../components/ecoforest/strings.json | 27 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json index 518f4d97a04..2ef33b2054b 100644 --- a/homeassistant/components/ecoforest/manifest.json +++ b/homeassistant/components/ecoforest/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecoforest", "iot_class": "local_polling", - "requirements": ["pyecoforest==0.3.0"] + "requirements": ["pyecoforest==0.4.0"] } diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index 91f3138af37..e595ddb65f7 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -13,7 +13,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -88,6 +93,59 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( icon="mdi:alert", value_fn=lambda data: data.alarm.value if data.alarm else "none", ), + EcoforestSensorEntityDescription( + key="depression", + translation_key="depression", + native_unit_of_measurement=UnitOfPressure.PA, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.depression, + ), + EcoforestSensorEntityDescription( + key="working_hours", + translation_key="working_hours", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.working_hours, + ), + EcoforestSensorEntityDescription( + key="ignitions", + translation_key="ignitions", + native_unit_of_measurement="ignitions", + entity_registry_enabled_default=False, + value_fn=lambda data: data.ignitions, + ), + EcoforestSensorEntityDescription( + key="live_pulse", + translation_key="live_pulse", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.live_pulse, + ), + EcoforestSensorEntityDescription( + key="pulse_offset", + translation_key="pulse_offset", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.pulse_offset, + ), + EcoforestSensorEntityDescription( + key="extractor", + translation_key="extractor", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.extractor, + ), + EcoforestSensorEntityDescription( + key="convecto_air_flow", + translation_key="convecto_air_flow", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.convecto_air_flow, + ), ) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index bd0605eab82..d1767be5cda 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -50,6 +50,33 @@ "unkownn": "Unknown alarm", "none": "None" } + }, + "depression": { + "name": "Depression" + }, + "working_hours": { + "name": "Working time" + }, + "working_state": { + "name": "Working state" + }, + "working_level": { + "name": "Working level" + }, + "ignitions": { + "name": "Ignitions" + }, + "live_pulse": { + "name": "Live pulse" + }, + "pulse_offset": { + "name": "Pulse offset" + }, + "extractor": { + "name": "Extractor" + }, + "convecto_air_flow": { + "name": "Convecto air flow" } }, "number": { diff --git a/requirements_all.txt b/requirements_all.txt index b6bf9982862..c89e0d28ee2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1681,7 +1681,7 @@ pydroid-ipcam==2.0.0 pyebox==1.1.4 # homeassistant.components.ecoforest -pyecoforest==0.3.0 +pyecoforest==0.4.0 # homeassistant.components.econet pyeconet==0.1.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3531966afcd..2044802be27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1266,7 +1266,7 @@ pydrawise==2023.10.0 pydroid-ipcam==2.0.0 # homeassistant.components.ecoforest -pyecoforest==0.3.0 +pyecoforest==0.4.0 # homeassistant.components.econet pyeconet==0.1.22 From 7c94293cb4aa92764a04848dfefa45c72fcbabbb Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 30 Oct 2023 08:46:20 +0000 Subject: [PATCH 081/982] Fix utility_meter reset when DST change occurs (#103012) --- .../components/utility_meter/sensor.py | 24 ++++++++++--------- tests/components/utility_meter/test_sensor.py | 14 ++++++++++- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index cd581d8c37f..794a65db03a 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -534,16 +534,25 @@ class UtilityMeterSensor(RestoreSensor): self.async_write_ha_state() - async def _async_reset_meter(self, event): - """Determine cycle - Helper function for larger than daily cycles.""" + async def _program_reset(self): + """Program the reset of the utility meter.""" if self._cron_pattern is not None: + tz = dt_util.get_time_zone(self.hass.config.time_zone) self.async_on_remove( async_track_point_in_time( self.hass, self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now()).get_next(datetime), + croniter(self._cron_pattern, dt_util.now(tz)).get_next( + datetime + ), # we need timezone for DST purposes (see issue #102984) ) ) + + async def _async_reset_meter(self, event): + """Reset the utility meter status.""" + + await self._program_reset() + await self.async_reset_meter(self._tariff_entity) async def async_reset_meter(self, entity_id): @@ -566,14 +575,7 @@ class UtilityMeterSensor(RestoreSensor): """Handle entity which will be added.""" await super().async_added_to_hass() - if self._cron_pattern is not None: - self.async_on_remove( - async_track_point_in_time( - self.hass, - self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now()).get_next(datetime), - ) - ) + await self._program_reset() self.async_on_remove( async_dispatcher_connect( diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 43d68d87362..2c64338c4f3 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1266,7 +1266,9 @@ async def _test_self_reset( state = hass.states.get("sensor.energy_bill") if expect_reset: assert state.attributes.get("last_period") == "2" - assert state.attributes.get("last_reset") == now.isoformat() + assert ( + state.attributes.get("last_reset") == dt_util.as_utc(now).isoformat() + ) # last_reset is kept in UTC assert state.state == "3" else: assert state.attributes.get("last_period") == "0" @@ -1348,6 +1350,16 @@ async def test_self_reset_hourly(hass: HomeAssistant) -> None: ) +async def test_self_reset_hourly_dst(hass: HomeAssistant) -> None: + """Test hourly reset of meter in DST change conditions.""" + + hass.config.time_zone = "Europe/Lisbon" + dt_util.set_default_time_zone(dt_util.get_time_zone(hass.config.time_zone)) + await _test_self_reset( + hass, gen_config("hourly"), "2023-10-29T01:59:00.000000+00:00" + ) + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( From dd3790641aa349b66012485083d02b51c8f2640d Mon Sep 17 00:00:00 2001 From: G-Two <7310260+G-Two@users.noreply.github.com> Date: Mon, 30 Oct 2023 04:46:48 -0400 Subject: [PATCH 082/982] Bump to subarulink 0.7.8 (#103033) --- homeassistant/components/subaru/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 9fae6ca9f73..0c4367c77c8 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.6"] + "requirements": ["subarulink==0.7.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index c89e0d28ee2..5bade76c2a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2512,7 +2512,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.6 +subarulink==0.7.8 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2044802be27..8a7040e2691 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1872,7 +1872,7 @@ stookwijzer==1.3.0 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.6 +subarulink==0.7.8 # homeassistant.components.solarlog sunwatcher==0.2.1 From 422af9d43846a2a7ec8ea8586e30f3f956253718 Mon Sep 17 00:00:00 2001 From: Jirka Date: Mon, 30 Oct 2023 09:54:46 +0100 Subject: [PATCH 083/982] Update MQTT QoS description string (#103036) Update strings.json --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 68fa39bfdc9..6197e580b1d 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -181,7 +181,7 @@ }, "qos": { "name": "QoS", - "description": "Quality of Service to use. O. At most once. 1: At least once. 2: Exactly once." + "description": "Quality of Service to use. 0: At most once. 1: At least once. 2: Exactly once." }, "retain": { "name": "Retain", From 8acc45d48251b342bc114d3326556105b550234b Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 30 Oct 2023 10:29:40 +0100 Subject: [PATCH 084/982] Enable dry mode for Tado AC's V3 (#99568) --- homeassistant/components/tado/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 9366a18b6fe..d6ae50c33c1 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -119,7 +119,7 @@ TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = { } # These modes will not allow a temp to be set -TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN] +TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_FAN] # # HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO # This lets tado decide on a temp From 9952eed671dead1bfd53ec4cb8d3ef4934e1a588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 30 Oct 2023 11:02:24 +0100 Subject: [PATCH 085/982] Show proper name on Airzone Cloud errors (#102998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone_cloud: fix showing None on errors Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: use entity_id on errors/logs Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/climate.py | 4 +++- .../components/airzone_cloud/entity.py | 20 +++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 53bc7e89a3c..e5aa6be65e3 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -386,4 +386,6 @@ class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneDeviceClimate): await self._async_update_params(params) if slave_raise: - raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}") + raise HomeAssistantError( + f"Mode can't be changed on slave zone {self.entity_id}" + ) diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 297f85af359..a175167be5a 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -80,14 +80,14 @@ class AirzoneAidooEntity(AirzoneEntity): async def _async_update_params(self, params: dict[str, Any]) -> None: """Send Aidoo parameters to Cloud API.""" - _LOGGER.debug("aidoo=%s: update_params=%s", self.name, params) + _LOGGER.debug("aidoo=%s: update_params=%s", self.entity_id, params) try: await self.coordinator.airzone.api_set_aidoo_id_params( self.aidoo_id, params ) except AirzoneCloudError as error: raise HomeAssistantError( - f"Failed to set {self.name} params: {error}" + f"Failed to set {self.entity_id} params: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) @@ -122,14 +122,14 @@ class AirzoneGroupEntity(AirzoneEntity): async def _async_update_params(self, params: dict[str, Any]) -> None: """Send Group parameters to Cloud API.""" - _LOGGER.debug("group=%s: update_params=%s", self.name, params) + _LOGGER.debug("group=%s: update_params=%s", self.entity_id, params) try: await self.coordinator.airzone.api_set_group_id_params( self.group_id, params ) except AirzoneCloudError as error: raise HomeAssistantError( - f"Failed to set {self.name} params: {error}" + f"Failed to set {self.entity_id} params: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) @@ -164,14 +164,18 @@ class AirzoneInstallationEntity(AirzoneEntity): async def _async_update_params(self, params: dict[str, Any]) -> None: """Send Installation parameters to Cloud API.""" - _LOGGER.debug("installation=%s: update_params=%s", self.name, params) + _LOGGER.debug( + "installation=%s: update_params=%s", + self.entity_id, + params, + ) try: await self.coordinator.airzone.api_set_installation_id_params( self.inst_id, params ) except AirzoneCloudError as error: raise HomeAssistantError( - f"Failed to set {self.name} params: {error}" + f"Failed to set {self.entity_id} params: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) @@ -267,12 +271,12 @@ class AirzoneZoneEntity(AirzoneEntity): async def _async_update_params(self, params: dict[str, Any]) -> None: """Send Zone parameters to Cloud API.""" - _LOGGER.debug("zone=%s: update_params=%s", self.name, params) + _LOGGER.debug("zone=%s: update_params=%s", self.entity_id, params) try: await self.coordinator.airzone.api_set_zone_id_params(self.zone_id, params) except AirzoneCloudError as error: raise HomeAssistantError( - f"Failed to set {self.name} params: {error}" + f"Failed to set {self.entity_id} params: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) From ba7dbc59275f176b1af93af13061729d01d26424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 30 Oct 2023 11:03:34 +0100 Subject: [PATCH 086/982] Show proper name on Airzone errors (#102997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: fix showing None on errors Signed-off-by: Álvaro Fernández Rojas * airzone: use entity_id on erros/logs Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/climate.py | 4 +++- homeassistant/components/airzone/entity.py | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index b4cf3d9d522..adbc6e1ff6e 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -208,7 +208,9 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): await self._async_update_hvac_params(params) if slave_raise: - raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}") + raise HomeAssistantError( + f"Mode can't be changed on slave zone {self.entity_id}" + ) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index b758acd4b75..2c3dba472ef 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -118,9 +118,7 @@ class AirzoneHotWaterEntity(AirzoneEntity): try: await self.coordinator.airzone.set_dhw_parameters(_params) except AirzoneError as error: - raise HomeAssistantError( - f"Failed to set dhw {self.name}: {error}" - ) from error + raise HomeAssistantError(f"Failed to set DHW: {error}") from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) @@ -205,7 +203,7 @@ class AirzoneZoneEntity(AirzoneEntity): await self.coordinator.airzone.set_hvac_parameters(_params) except AirzoneError as error: raise HomeAssistantError( - f"Failed to set zone {self.name}: {error}" + f"Failed to set zone {self.entity_id}: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) From 7dbe0c3a48cff7e4957ac41d5580a3d2c4f81412 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 30 Oct 2023 07:36:34 -0400 Subject: [PATCH 087/982] Fix Google Mail expired authorization (#102735) * Fix Google Mail expired authorization * add test * raise HomeAssistantError * handle in api module * uno mas --- .../components/google_mail/__init__.py | 14 +------- homeassistant/components/google_mail/api.py | 35 +++++++++++++++---- tests/components/google_mail/test_init.py | 7 +++- tests/components/google_mail/test_services.py | 15 ++++++-- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 15c4192ccf5..96639e4a547 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -1,12 +1,9 @@ """Support for Google Mail.""" from __future__ import annotations -from aiohttp.client_exceptions import ClientError, ClientResponseError - from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -35,16 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(session) - try: - await auth.check_and_refresh_token() - except ClientResponseError as err: - if 400 <= err.status < 500: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - raise ConfigEntryNotReady from err - except ClientError as err: - raise ConfigEntryNotReady from err + await auth.check_and_refresh_token() hass.data[DOMAIN][entry.entry_id] = auth hass.async_create_task( diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index ffa33deae14..10b2fec7467 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -1,9 +1,16 @@ """API for Google Mail bound to Home Assistant OAuth.""" +from aiohttp.client_exceptions import ClientError, ClientResponseError from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import config_entry_oauth2_flow @@ -24,14 +31,30 @@ class AsyncConfigEntryAuth: async def check_and_refresh_token(self) -> str: """Check the token.""" - await self.oauth_session.async_ensure_token_valid() + try: + await self.oauth_session.async_ensure_token_valid() + except (RefreshError, ClientResponseError, ClientError) as ex: + if ( + self.oauth_session.config_entry.state + is ConfigEntryState.SETUP_IN_PROGRESS + ): + if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + raise ConfigEntryNotReady from ex + if ( + isinstance(ex, RefreshError) + or hasattr(ex, "status") + and ex.status == 400 + ): + self.oauth_session.config_entry.async_start_reauth( + self.oauth_session.hass + ) + raise HomeAssistantError(ex) from ex return self.access_token async def get_resource(self) -> Resource: """Get current resource.""" - try: - credentials = Credentials(await self.check_and_refresh_token()) - except RefreshError as ex: - self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) - raise ex + credentials = Credentials(await self.check_and_refresh_token()) return build("gmail", "v1", credentials=credentials) diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index a069ae0807b..4882fd10e80 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -73,8 +73,13 @@ async def test_expired_token_refresh_success( http.HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY, ), + ( + time.time() - 3600, + http.HTTPStatus.BAD_REQUEST, + ConfigEntryState.SETUP_ERROR, + ), ], - ids=["failure_requires_reauth", "transient_failure"], + ids=["failure_requires_reauth", "transient_failure", "revoked_auth"], ) async def test_expired_token_refresh_failure( hass: HomeAssistant, diff --git a/tests/components/google_mail/test_services.py b/tests/components/google_mail/test_services.py index b9fefa805e6..caa0d887dec 100644 --- a/tests/components/google_mail/test_services.py +++ b/tests/components/google_mail/test_services.py @@ -1,12 +1,14 @@ """Services tests for the Google Mail integration.""" from unittest.mock import patch +from aiohttp.client_exceptions import ClientResponseError from google.auth.exceptions import RefreshError import pytest from homeassistant import config_entries from homeassistant.components.google_mail import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import BUILD, SENSOR, TOKEN, ComponentSetup @@ -57,13 +59,22 @@ async def test_set_vacation( assert len(mock_client.mock_calls) == 5 +@pytest.mark.parametrize( + ("side_effect"), + ( + (RefreshError,), + (ClientResponseError("", (), status=400),), + ), +) async def test_reauth_trigger( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + setup_integration: ComponentSetup, + side_effect, ) -> None: """Test reauth is triggered after a refresh error during service call.""" await setup_integration() - with patch(TOKEN, side_effect=RefreshError), pytest.raises(RefreshError): + with patch(TOKEN, side_effect=side_effect), pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, "set_vacation", From b3743937de96b9a339ff99b471f126383ec70d56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Oct 2023 06:45:22 -0500 Subject: [PATCH 088/982] Avoid looking up the callable type for HassJob when we already know it (#102962) * Avoid looking up the callable type for HassJob when we already know it When we connect the frontend we call async_listen with run_immediately MANY times when we already know the job type (it will always be a callback). This reduces the latency to get the frontend going * missing coverage --- homeassistant/core.py | 22 ++++++++++---- homeassistant/helpers/event.py | 8 +++++- homeassistant/helpers/update_coordinator.py | 15 ++++++++-- tests/test_core.py | 32 +++++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 48cc70e7727..01a3dd7fbe6 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -282,11 +282,12 @@ class HassJob(Generic[_P, _R_co]): name: str | None = None, *, cancel_on_shutdown: bool | None = None, + job_type: HassJobType | None = None, ) -> None: """Create a job object.""" self.target = target self.name = name - self.job_type = _get_hassjob_callable_job_type(target) + self.job_type = job_type or _get_hassjob_callable_job_type(target) self._cancel_on_shutdown = cancel_on_shutdown @property @@ -1153,13 +1154,20 @@ class EventBus: This method must be run in the event loop. """ + job_type: HassJobType | None = None if event_filter is not None and not is_callback_check_partial(event_filter): raise HomeAssistantError(f"Event filter {event_filter} is not a callback") - if run_immediately and not is_callback_check_partial(listener): - raise HomeAssistantError(f"Event listener {listener} is not a callback") + if run_immediately: + if not is_callback_check_partial(listener): + raise HomeAssistantError(f"Event listener {listener} is not a callback") + job_type = HassJobType.Callback return self._async_listen_filterable_job( event_type, - (HassJob(listener, f"listen {event_type}"), event_filter, run_immediately), + ( + HassJob(listener, f"listen {event_type}", job_type=job_type), + event_filter, + run_immediately, + ), ) @callback @@ -1234,7 +1242,11 @@ class EventBus: ) filterable_job = ( - HassJob(_onetime_listener, f"onetime listen {event_type} {listener}"), + HassJob( + _onetime_listener, + f"onetime listen {event_type} {listener}", + job_type=HassJobType.Callback, + ), None, False, ) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index ab0fc25f04d..648e0e5bd09 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import ( CALLBACK_TYPE, HassJob, + HassJobType, HomeAssistant, State, callback, @@ -1376,6 +1377,7 @@ def async_track_point_in_time( utc_converter, name=f"{job.name} UTC converter", cancel_on_shutdown=job.cancel_on_shutdown, + job_type=HassJobType.Callback, ) return async_track_point_in_utc_time(hass, track_job, point_in_time) @@ -1531,7 +1533,10 @@ def async_track_time_interval( job_name = f"track time interval {interval} {action}" interval_listener_job = HassJob( - interval_listener, job_name, cancel_on_shutdown=cancel_on_shutdown + interval_listener, + job_name, + cancel_on_shutdown=cancel_on_shutdown, + job_type=HassJobType.Callback, ) remove = async_call_later(hass, interval_seconds, interval_listener_job) @@ -1703,6 +1708,7 @@ def async_track_utc_time_change( pattern_time_change_listener_job = HassJob( pattern_time_change_listener, f"time change listener {hour}:{minute}:{second} {action}", + job_type=HassJobType.Callback, ) time_listener = async_track_point_in_utc_time( hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow()) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 2b570009a57..b74c22c9ead 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -16,7 +16,14 @@ import requests from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + callback, +) from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -104,7 +111,11 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): job_name += f" {name}" if entry := self.config_entry: job_name += f" {entry.title} {entry.domain} {entry.entry_id}" - self._job = HassJob(self._handle_refresh_interval, job_name) + self._job = HassJob( + self._handle_refresh_interval, + job_name, + job_type=HassJobType.Coroutinefunction, + ) self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None diff --git a/tests/test_core.py b/tests/test_core.py index 9fed1141a76..43291c032d7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -816,6 +816,16 @@ async def test_eventbus_run_immediately(hass: HomeAssistant) -> None: unsub() +async def test_eventbus_run_immediately_not_callback(hass: HomeAssistant) -> None: + """Test we raise when passing a non-callback with run_immediately.""" + + def listener(event): + """Mock listener.""" + + with pytest.raises(HomeAssistantError): + hass.bus.async_listen("test", listener, run_immediately=True) + + async def test_eventbus_unsubscribe_listener(hass: HomeAssistant) -> None: """Test unsubscribe listener from returned function.""" calls = [] @@ -2534,3 +2544,25 @@ def test_is_callback_check_partial(): assert HassJob(ha.callback(functools.partial(not_callback_func))).job_type == ( ha.HassJobType.Executor ) + + +def test_hassjob_passing_job_type(): + """Test passing the job type to HassJob when we already know it.""" + + @ha.callback + def callback_func(): + pass + + def not_callback_func(): + pass + + assert ( + HassJob(callback_func, job_type=ha.HassJobType.Callback).job_type + == ha.HassJobType.Callback + ) + + # We should trust the job_type passed in + assert ( + HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type + == ha.HassJobType.Callback + ) From 487dcf227ef70a3634206ba5d5c0c152317ace7b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Oct 2023 14:07:42 +0100 Subject: [PATCH 089/982] Rewrite HomeWizard Energy tests (#103000) Co-authored-by: Duco Sebel <74970928+DCSBL@users.noreply.github.com> --- tests/components/homewizard/conftest.py | 109 +- .../fixtures/data-HWE-P1-unused-exports.json | 45 + .../fixtures/{data.json => data-HWE-P1.json} | 4 + .../homewizard/fixtures/device-HWE-P1.json | 7 + .../{device.json => device-HWE-SKT.json} | 4 +- .../homewizard/fixtures/device-sdm230.json | 7 + tests/components/homewizard/generator.py | 34 - .../homewizard/snapshots/test_button.ambr | 77 + .../snapshots/test_config_flow.ambr | 153 + .../snapshots/test_diagnostics.ambr | 142 + .../homewizard/snapshots/test_number.ambr | 87 + .../homewizard/snapshots/test_sensor.ambr | 4546 +++++++++++++++++ .../homewizard/snapshots/test_switch.ambr | 229 + tests/components/homewizard/test_button.py | 168 +- .../components/homewizard/test_config_flow.py | 581 +-- .../components/homewizard/test_diagnostics.py | 4 + tests/components/homewizard/test_init.py | 215 +- tests/components/homewizard/test_number.py | 293 +- tests/components/homewizard/test_sensor.py | 1863 +------ tests/components/homewizard/test_switch.py | 583 +-- 20 files changed, 5953 insertions(+), 3198 deletions(-) create mode 100644 tests/components/homewizard/fixtures/data-HWE-P1-unused-exports.json rename tests/components/homewizard/fixtures/{data.json => data-HWE-P1.json} (88%) create mode 100644 tests/components/homewizard/fixtures/device-HWE-P1.json rename tests/components/homewizard/fixtures/{device.json => device-HWE-SKT.json} (56%) create mode 100644 tests/components/homewizard/fixtures/device-sdm230.json delete mode 100644 tests/components/homewizard/generator.py create mode 100644 tests/components/homewizard/snapshots/test_button.ambr create mode 100644 tests/components/homewizard/snapshots/test_config_flow.ambr create mode 100644 tests/components/homewizard/snapshots/test_number.ambr create mode 100644 tests/components/homewizard/snapshots/test_sensor.ambr create mode 100644 tests/components/homewizard/snapshots/test_switch.ambr diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index 4cfec96cb8f..9124504b23e 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -14,58 +14,85 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture -def mock_config_entry_data(): - """Return the default mocked config entry data.""" - return { - "product_name": "Product Name", - "product_type": "product_type", - "serial": "aabbccddeeff", - "name": "Product Name", - CONF_IP_ADDRESS: "1.2.3.4", - } +def device_fixture() -> str: + """Return the device fixture for a specific device.""" + return "device-HWE-P1.json" + + +@pytest.fixture +def data_fixture() -> str: + """Return the data fixture for a specific device.""" + return "data-HWE-P1.json" + + +@pytest.fixture +def state_fixture() -> str: + """Return the state fixture for a specific device.""" + return "state.json" + + +@pytest.fixture +def system_fixture() -> str: + """Return the system fixture for a specific device.""" + return "system.json" + + +@pytest.fixture +def mock_homewizardenergy( + device_fixture: str, + data_fixture: str, + state_fixture: str, + system_fixture: str, +) -> MagicMock: + """Return a mock bridge.""" + with patch( + "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", + autospec=True, + ) as homewizard, patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", + new=homewizard, + ): + client = homewizard.return_value + client.device.return_value = Device.from_dict( + json.loads(load_fixture(device_fixture, DOMAIN)) + ) + client.data.return_value = Data.from_dict( + json.loads(load_fixture(data_fixture, DOMAIN)) + ) + client.state.return_value = State.from_dict( + json.loads(load_fixture(state_fixture, DOMAIN)) + ) + client.system.return_value = System.from_dict( + json.loads(load_fixture(system_fixture, DOMAIN)) + ) + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.homewizard.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - title="Product Name (aabbccddeeff)", + title="Device", domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.2.3.4"}, + data={ + "product_name": "Product name", + "product_type": "product_type", + "serial": "aabbccddeeff", + CONF_IP_ADDRESS: "127.0.0.1", + }, unique_id="aabbccddeeff", ) -@pytest.fixture -def mock_homewizardenergy(): - """Return a mocked all-feature device.""" - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - ) as device: - client = device.return_value - client.device = AsyncMock( - side_effect=lambda: Device.from_dict( - json.loads(load_fixture("homewizard/device.json")) - ) - ) - client.data = AsyncMock( - side_effect=lambda: Data.from_dict( - json.loads(load_fixture("homewizard/data.json")) - ) - ) - client.state = AsyncMock( - side_effect=lambda: State.from_dict( - json.loads(load_fixture("homewizard/state.json")) - ) - ) - client.system = AsyncMock( - side_effect=lambda: System.from_dict( - json.loads(load_fixture("homewizard/system.json")) - ) - ) - yield device - - @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/homewizard/fixtures/data-HWE-P1-unused-exports.json b/tests/components/homewizard/fixtures/data-HWE-P1-unused-exports.json new file mode 100644 index 00000000000..03cadf99ec5 --- /dev/null +++ b/tests/components/homewizard/fixtures/data-HWE-P1-unused-exports.json @@ -0,0 +1,45 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 100, + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "00112233445566778899AABBCCDDEEFF", + "active_tariff": 2, + "total_power_import_kwh": 13779.338, + "total_power_import_t1_kwh": 10830.511, + "total_power_import_t2_kwh": 2948.827, + "total_power_import_t3_kwh": 2948.827, + "total_power_import_t4_kwh": 2948.827, + "total_power_export_kwh": 0, + "total_power_export_t1_kwh": 0, + "total_power_export_t2_kwh": 0, + "total_power_export_t3_kwh": 0, + "total_power_export_t4_kwh": 0, + "active_power_w": -123, + "active_power_l1_w": -123, + "active_power_l2_w": 456, + "active_power_l3_w": 123.456, + "active_voltage_l1_v": 230.111, + "active_voltage_l2_v": 230.222, + "active_voltage_l3_v": 230.333, + "active_current_l1_a": -4, + "active_current_l2_a": 2, + "active_current_l3_a": 0, + "active_frequency_hz": 50, + "voltage_sag_l1_count": 1, + "voltage_sag_l2_count": 2, + "voltage_sag_l3_count": 3, + "voltage_swell_l1_count": 4, + "voltage_swell_l2_count": 5, + "voltage_swell_l3_count": 6, + "any_power_fail_count": 4, + "long_power_fail_count": 5, + "total_gas_m3": 1122.333, + "gas_timestamp": 210314112233, + "gas_unique_id": "01FFEEDDCCBBAA99887766554433221100", + "active_power_average_w": 123.0, + "montly_power_peak_w": 1111.0, + "montly_power_peak_timestamp": 230101080010, + "active_liter_lpm": 12.345, + "total_liter_m3": 1234.567 +} diff --git a/tests/components/homewizard/fixtures/data.json b/tests/components/homewizard/fixtures/data-HWE-P1.json similarity index 88% rename from tests/components/homewizard/fixtures/data.json rename to tests/components/homewizard/fixtures/data-HWE-P1.json index f73d3ac1a19..2eb7e3e430b 100644 --- a/tests/components/homewizard/fixtures/data.json +++ b/tests/components/homewizard/fixtures/data-HWE-P1.json @@ -8,9 +8,13 @@ "total_power_import_kwh": 13779.338, "total_power_import_t1_kwh": 10830.511, "total_power_import_t2_kwh": 2948.827, + "total_power_import_t3_kwh": 2948.827, + "total_power_import_t4_kwh": 2948.827, "total_power_export_kwh": 13086.777, "total_power_export_t1_kwh": 4321.333, "total_power_export_t2_kwh": 8765.444, + "total_power_export_t3_kwh": 8765.444, + "total_power_export_t4_kwh": 8765.444, "active_power_w": -123, "active_power_l1_w": -123, "active_power_l2_w": 456, diff --git a/tests/components/homewizard/fixtures/device-HWE-P1.json b/tests/components/homewizard/fixtures/device-HWE-P1.json new file mode 100644 index 00000000000..4972c491859 --- /dev/null +++ b/tests/components/homewizard/fixtures/device-HWE-P1.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "3c39e7aabbcc", + "firmware_version": "4.19", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/device.json b/tests/components/homewizard/fixtures/device-HWE-SKT.json similarity index 56% rename from tests/components/homewizard/fixtures/device.json rename to tests/components/homewizard/fixtures/device-HWE-SKT.json index 2e5be55c68e..bab5a636368 100644 --- a/tests/components/homewizard/fixtures/device.json +++ b/tests/components/homewizard/fixtures/device-HWE-SKT.json @@ -1,7 +1,7 @@ { "product_type": "HWE-SKT", - "product_name": "P1 Meter", + "product_name": "Energy Socket", "serial": "3c39e7aabbcc", - "firmware_version": "2.11", + "firmware_version": "3.03", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/device-sdm230.json b/tests/components/homewizard/fixtures/device-sdm230.json new file mode 100644 index 00000000000..cd8a58341a7 --- /dev/null +++ b/tests/components/homewizard/fixtures/device-sdm230.json @@ -0,0 +1,7 @@ +{ + "product_type": "SDM230-WIFI", + "product_name": "kWh meter", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/generator.py b/tests/components/homewizard/generator.py deleted file mode 100644 index 6eb945334fd..00000000000 --- a/tests/components/homewizard/generator.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Helper files for unit tests.""" - -from unittest.mock import AsyncMock - -from homewizard_energy.models import Data, Device - - -def get_mock_device( - serial="aabbccddeeff", - host="1.2.3.4", - product_name="P1 meter", - product_type="HWE-P1", - firmware_version="1.00", -): - """Return a mock bridge.""" - mock_device = AsyncMock() - mock_device.host = host - - mock_device.device = AsyncMock( - return_value=Device( - product_name=product_name, - product_type=product_type, - serial=serial, - api_version="V1", - firmware_version=firmware_version, - ) - ) - mock_device.data = AsyncMock(return_value=Data.from_dict({})) - mock_device.state = AsyncMock(return_value=None) - mock_device.system = AsyncMock(return_value=None) - - mock_device.close = AsyncMock() - - return mock_device diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr new file mode 100644 index 00000000000..2e6422f7a2d --- /dev/null +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_identify_button + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Device Identify', + }), + 'context': , + 'entity_id': 'button.device_identify', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_identify_button.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.device_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_identify_button.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..b5b7411532e --- /dev/null +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -0,0 +1,153 @@ +# serializer version: 1 +# name: test_discovery_flow_during_onboarding + FlowResultSnapshot({ + 'context': dict({ + 'source': 'zeroconf', + 'unique_id': 'HWE-P1_aabbccddeeff', + }), + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'homewizard', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'disabled_by': None, + 'domain': 'homewizard', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'P1 meter', + 'unique_id': 'HWE-P1_aabbccddeeff', + 'version': 1, + }), + 'title': 'P1 meter', + 'type': , + 'version': 1, + }) +# --- +# name: test_discovery_flow_during_onboarding_disabled_api + FlowResultSnapshot({ + 'context': dict({ + 'confirm_only': True, + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'P1 meter', + }), + 'unique_id': 'HWE-P1_aabbccddeeff', + }), + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'homewizard', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'disabled_by': None, + 'domain': 'homewizard', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'P1 meter', + 'unique_id': 'HWE-P1_aabbccddeeff', + 'version': 1, + }), + 'title': 'P1 meter', + 'type': , + 'version': 1, + }) +# --- +# name: test_discovery_flow_works + FlowResultSnapshot({ + 'context': dict({ + 'confirm_only': True, + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Energy Socket (aabbccddeeff)', + }), + 'unique_id': 'HWE-SKT_aabbccddeeff', + }), + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'homewizard', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'disabled_by': None, + 'domain': 'homewizard', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Energy Socket', + 'unique_id': 'HWE-SKT_aabbccddeeff', + 'version': 1, + }), + 'title': 'Energy Socket', + 'type': , + 'version': 1, + }) +# --- +# name: test_manual_flow_works + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'HWE-P1_3c39e7aabbcc', + }), + 'data': dict({ + 'ip_address': '2.2.2.2', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'homewizard', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'ip_address': '2.2.2.2', + }), + 'disabled_by': None, + 'domain': 'homewizard', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'P1 meter', + 'unique_id': 'HWE-P1_3c39e7aabbcc', + 'version': 1, + }), + 'title': 'P1 meter', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 5e1025a8d31..42697e882fa 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -69,3 +69,145 @@ }), }) # --- +# name: test_diagnostics[device-HWE-P1.json] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': -4, + 'active_current_l2_a': 2, + 'active_current_l3_a': 0, + 'active_frequency_hz': 50, + 'active_liter_lpm': 12.345, + 'active_power_average_w': 123.0, + 'active_power_l1_w': -123, + 'active_power_l2_w': 456, + 'active_power_l3_w': 123.456, + 'active_power_w': -123, + 'active_tariff': 2, + 'active_voltage_l1_v': 230.111, + 'active_voltage_l2_v': 230.222, + 'active_voltage_l3_v': 230.333, + 'any_power_fail_count': 4, + 'external_devices': None, + 'gas_timestamp': '2021-03-14T11:22:33', + 'gas_unique_id': '**REDACTED**', + 'long_power_fail_count': 5, + 'meter_model': 'ISKRA 2M550T-101', + 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', + 'monthly_power_peak_w': 1111.0, + 'smr_version': 50, + 'total_gas_m3': 1122.333, + 'total_liter_m3': 1234.567, + 'total_power_export_kwh': 13086.777, + 'total_power_export_t1_kwh': 4321.333, + 'total_power_export_t2_kwh': 8765.444, + 'total_power_export_t3_kwh': 8765.444, + 'total_power_export_t4_kwh': 8765.444, + 'total_power_import_kwh': 13779.338, + 'total_power_import_t1_kwh': 10830.511, + 'total_power_import_t2_kwh': 2948.827, + 'total_power_import_t3_kwh': 2948.827, + 'total_power_import_t4_kwh': 2948.827, + 'unique_meter_id': '**REDACTED**', + 'voltage_sag_l1_count': 1, + 'voltage_sag_l2_count': 2, + 'voltage_sag_l3_count': 3, + 'voltage_swell_l1_count': 4, + 'voltage_swell_l2_count': 5, + 'voltage_swell_l3_count': 6, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '4.19', + 'product_name': 'P1 meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[device-HWE-SKT.json] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': -4, + 'active_current_l2_a': 2, + 'active_current_l3_a': 0, + 'active_frequency_hz': 50, + 'active_liter_lpm': 12.345, + 'active_power_average_w': 123.0, + 'active_power_l1_w': -123, + 'active_power_l2_w': 456, + 'active_power_l3_w': 123.456, + 'active_power_w': -123, + 'active_tariff': 2, + 'active_voltage_l1_v': 230.111, + 'active_voltage_l2_v': 230.222, + 'active_voltage_l3_v': 230.333, + 'any_power_fail_count': 4, + 'external_devices': None, + 'gas_timestamp': '2021-03-14T11:22:33', + 'gas_unique_id': '**REDACTED**', + 'long_power_fail_count': 5, + 'meter_model': 'ISKRA 2M550T-101', + 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', + 'monthly_power_peak_w': 1111.0, + 'smr_version': 50, + 'total_gas_m3': 1122.333, + 'total_liter_m3': 1234.567, + 'total_power_export_kwh': 13086.777, + 'total_power_export_t1_kwh': 4321.333, + 'total_power_export_t2_kwh': 8765.444, + 'total_power_export_t3_kwh': 8765.444, + 'total_power_export_t4_kwh': 8765.444, + 'total_power_import_kwh': 13779.338, + 'total_power_import_t1_kwh': 10830.511, + 'total_power_import_t2_kwh': 2948.827, + 'total_power_import_t3_kwh': 2948.827, + 'total_power_import_t4_kwh': 2948.827, + 'unique_meter_id': '**REDACTED**', + 'voltage_sag_l1_count': 1, + 'voltage_sag_l2_count': 2, + 'voltage_sag_l3_count': 3, + 'voltage_swell_l1_count': 4, + 'voltage_swell_l2_count': 5, + 'voltage_swell_l3_count': 6, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.03', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'state': dict({ + 'brightness': 255, + 'power_on': True, + 'switch_lock': False, + }), + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr new file mode 100644 index 00000000000..ea12108e9de --- /dev/null +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_number_entities[device-HWE-SKT.json] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Status light brightness', + 'icon': 'mdi:lightbulb-on', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.device_status_light_brightness', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_entities[device-HWE-SKT.json].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.device_status_light_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lightbulb-on', + 'original_name': 'Status light brightness', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_light_brightness', + 'unique_id': 'aabbccddeeff_status_light_brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities[device-HWE-SKT.json].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..76df8ed9e29 --- /dev/null +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -0,0 +1,4546 @@ +# serializer version: 1 +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_average_demand:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_average_demand:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_average_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active average demand', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_average_w', + 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_average_demand:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active average demand', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_average_demand', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_average_demand] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l1_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-4', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l2_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l3_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_frequency_hz', + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Active frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_frequency] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-123', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-123', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l2_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '456', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l3_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_tariff:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_tariff:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:calendar-clock', + 'original_name': 'Active tariff', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'aabbccddeeff_active_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_tariff:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Device Active tariff', + 'icon': 'mdi:calendar-clock', + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'context': , + 'entity_id': 'sensor.device_active_tariff', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_tariff] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l1_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.111', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l2_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '230.222', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l3_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '230.333', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Active water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Active water usage', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_active_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.345', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_water_usage] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_dsmr_version:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_dsmr_version:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_dsmr_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:counter', + 'original_name': 'DSMR version', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dsmr_version', + 'unique_id': 'aabbccddeeff_smr_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_dsmr_version:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device DSMR version', + 'icon': 'mdi:counter', + }), + 'context': , + 'entity_id': 'sensor.device_dsmr_version', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_dsmr_version] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_gas_meter_identifier:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_gas_meter_identifier:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_gas_meter_identifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': 'Gas meter identifier', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_unique_id', + 'unique_id': 'aabbccddeeff_gas_unique_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_gas_meter_identifier:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Gas meter identifier', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.device_gas_meter_identifier', + 'last_changed': , + 'last_updated': , + 'state': '01FFEEDDCCBBAA99887766554433221100', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_gas_meter_identifier] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_long_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_long_power_failures_detected:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:transmission-tower-off', + 'original_name': 'Long power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'long_power_fail_count', + 'unique_id': 'aabbccddeeff_long_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_long_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Long power failures detected', + 'icon': 'mdi:transmission-tower-off', + }), + 'context': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_long_power_failures_detected] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_peak_demand_current_month:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_peak_demand_current_month:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_peak_demand_current_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak demand current month', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_power_peak_w', + 'unique_id': 'aabbccddeeff_monthly_power_peak_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_peak_demand_current_month:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Peak demand current month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_peak_demand_current_month', + 'last_changed': , + 'last_updated': , + 'state': '1111.0', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_peak_demand_current_month] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_power_failures_detected:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:transmission-tower-off', + 'original_name': 'Power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'any_power_fail_count', + 'unique_id': 'aabbccddeeff_any_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Power failures detected', + 'icon': 'mdi:transmission-tower-off', + }), + 'context': , + 'entity_id': 'sensor.device_power_failures_detected', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_power_failures_detected] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_identifier:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_identifier:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_smart_meter_identifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': 'Smart meter identifier', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'unique_meter_id', + 'unique_id': 'aabbccddeeff_unique_meter_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_identifier:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Smart meter identifier', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.device_smart_meter_identifier', + 'last_changed': , + 'last_updated': , + 'state': '00112233445566778899AABBCCDDEEFF', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_identifier] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_model:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_model:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_smart_meter_model', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:gauge', + 'original_name': 'Smart meter model', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_model', + 'unique_id': 'aabbccddeeff_meter_model', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_model:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Smart meter model', + 'icon': 'mdi:gauge', + }), + 'context': , + 'entity_id': 'sensor.device_smart_meter_model', + 'last_changed': , + 'last_updated': , + 'state': 'ISKRA 2M550T-101', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_model] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_m3', + 'unique_id': 'aabbccddeeff_total_gas_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Device Total gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_gas', + 'last_changed': , + 'last_updated': , + 'state': '1122.333', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_export', + 'last_changed': , + 'last_updated': , + 'state': '13086.777', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '4321.333', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_export_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_export_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_export_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_export_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_export_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_export_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_4] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_import', + 'last_changed': , + 'last_updated': , + 'state': '13779.338', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '10830.511', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_import_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_import_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_import_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_power_import_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_import_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total power import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_power_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_4] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'icon': 'mdi:gauge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '1234.567', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_water_usage] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l1_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 1', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l2_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 2', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l3_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 3', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l1_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 1', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l2_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 2', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l3_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 3', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_ssid] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_strength] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr new file mode 100644 index 00000000000..f6bd7c1d9eb --- /dev/null +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -0,0 +1,229 @@ +# serializer version: 1 +# name: test_switch_entities[switch.device-state_set-power_on-device-HWE-SKT.json] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Device', + }), + 'context': , + 'entity_id': 'switch.device', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[switch.device-state_set-power_on-device-HWE-SKT.json].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_power_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.device-state_set-power_on-device-HWE-SKT.json].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-device-HWE-SKT.json] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-device-HWE-SKT.json].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-device-HWE-SKT.json].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-device-HWE-SKT.json] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Switch lock', + 'icon': 'mdi:lock-open', + }), + 'context': , + 'entity_id': 'switch.device_switch_lock', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-device-HWE-SKT.json].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.device_switch_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Switch lock', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'switch_lock', + 'unique_id': 'aabbccddeeff_switch_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-device-HWE-SKT.json].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index d8b8b5030b6..97989e17a6e 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -1,174 +1,86 @@ """Test the identify button for HomeWizard.""" -from unittest.mock import patch +from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import button -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .generator import get_mock_device +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2021-01-01 12:00:00"), +] +@pytest.mark.parametrize("device_fixture", ["device-sdm230.json"]) async def test_identify_button_entity_not_loaded_when_not_available( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry + hass: HomeAssistant, ) -> None: """Does not load button when device has no support for it.""" - - api = get_mock_device(product_type="SDM230-WIFI") - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get("button.product_name_aabbccddeeff_identify") is None + assert not hass.states.get("button.device_identify") -async def test_identify_button_is_loaded( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +async def test_identify_button( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_homewizardenergy: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Loads button when device has support.""" + assert (state := hass.states.get("button.device_identify")) + assert snapshot == state - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("button.product_name_aabbccddeeff_identify") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Identify" - ) - - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("button.product_name_aabbccddeeff_identify") - assert entry - assert entry.unique_id == "aabbccddeeff_identify" - - -async def test_identify_press( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test button press is handled correctly.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("button.product_name_aabbccddeeff_identify").state - == STATE_UNKNOWN - ) - - assert api.identify.call_count == 0 + assert len(mock_homewizardenergy.identify.mock_calls) == 0 await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, - {"entity_id": "button.product_name_aabbccddeeff_identify"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert api.identify.call_count == 1 + assert len(mock_homewizardenergy.identify.mock_calls) == 1 - -async def test_identify_press_catches_requesterror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test button press is handled RequestError correctly.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("button.product_name_aabbccddeeff_identify").state - == STATE_UNKNOWN - ) + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2021-01-01T12:00:00+00:00" # Raise RequestError when identify is called - api.identify.side_effect = RequestError() - - assert api.identify.call_count == 0 + mock_homewizardenergy.identify.side_effect = RequestError() with pytest.raises(HomeAssistantError): await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, - {"entity_id": "button.product_name_aabbccddeeff_identify"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert api.identify.call_count == 1 + assert len(mock_homewizardenergy.identify.mock_calls) == 2 - -async def test_identify_press_catches_disablederror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test button press is handled DisabledError correctly.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("button.product_name_aabbccddeeff_identify").state - == STATE_UNKNOWN - ) + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2021-01-01T12:00:00+00:00" # Raise RequestError when identify is called - api.identify.side_effect = DisabledError() - - assert api.identify.call_count == 0 + mock_homewizardenergy.identify.side_effect = DisabledError() with pytest.raises(HomeAssistantError): await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, - {"entity_id": "button.product_name_aabbccddeeff_identify"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert api.identify.call_count == 1 + + assert len(mock_homewizardenergy.identify.mock_calls) == 3 + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2021-01-01T12:00:00+00:00" diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 770496b5612..5e71826b28d 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,8 +1,10 @@ """Test the homewizard config flow.""" from ipaddress import ip_address -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components import zeroconf @@ -11,371 +13,308 @@ from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .generator import get_mock_device - from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.usefixtures("mock_setup_entry") async def test_manual_flow_works( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test config flow accepts user configuration.""" - - device = get_mock_device() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ), patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) - assert result["type"] == "create_entry" - assert result["title"] == "P1 meter" - assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result == snapshot assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - assert len(device.close.mock_calls) == len(device.device.mock_calls) - + assert len(mock_homewizardenergy.close.mock_calls) == 1 + assert len(mock_homewizardenergy.device.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry") async def test_discovery_flow_works( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + snapshot: SnapshotAssertion, ) -> None: """Test discovery setup flow works.""" - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "1", - "path": "/api/v1", - "product_name": "Energy Socket", - "product_type": "HWE-SKT", - "serial": "aabbccddeeff", - }, + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "Energy Socket", + "product_type": "HWE-SKT", + "serial": "aabbccddeeff", + }, + ), ) - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=get_mock_device(), - ): - flow = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=service_info, - ) - - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=get_mock_device(), - ): - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input=None - ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "discovery_confirm" - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=get_mock_device(), - ): - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={"ip_address": "192.168.43.183"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=None + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"ip_address": "127.0.0.1"} + ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Energy Socket" - assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" - - assert result["result"] - assert result["result"].unique_id == "HWE-SKT_aabbccddeeff" + assert result == snapshot +@pytest.mark.usefixtures("mock_homewizardenergy") async def test_discovery_flow_during_onboarding( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_onboarding: MagicMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test discovery setup flow during onboarding.""" - - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=get_mock_device(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="mock_type", - name="mock_name", - properties={ - "api_enabled": "1", - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ), - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="mock_type", + name="mock_name", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), + ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter" - assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" - - assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result == snapshot assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 async def test_discovery_flow_during_onboarding_disabled_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_onboarding: MagicMock + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test discovery setup flow during onboarding with a disabled API.""" + mock_homewizardenergy.device.side_effect = DisabledError - def mock_initialize(): - raise DisabledError - - device = get_mock_device() - device.device.side_effect = mock_initialize - - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="mock_type", - name="mock_name", - properties={ - "api_enabled": "0", - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ), - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="mock_type", + name="mock_name", + properties={ + "api_enabled": "0", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "discovery_confirm" assert result["errors"] == {"base": "api_not_enabled"} # We are onboarded, user enabled API again and picks up from discovery/config flow - device.device.side_effect = None + mock_homewizardenergy.device.side_effect = None mock_onboarding.return_value = True - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"ip_address": "192.168.43.183"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"ip_address": "127.0.0.1"} + ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter" - assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" - - assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result == snapshot assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 async def test_discovery_disabled_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, ) -> None: """Test discovery detecting disabled api.""" - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "0", - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=service_info, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "0", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), ) assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" - def mock_initialize(): - raise DisabledError + mock_homewizardenergy.device.side_effect = DisabledError - device = get_mock_device() - device.device.side_effect = mock_initialize - - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"ip_address": "192.168.43.183"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"ip_address": "127.0.0.1"} + ) assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "api_not_enabled"} -async def test_discovery_missing_data_in_service_info( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> None: """Test discovery detecting missing discovery info.""" - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - # "api_enabled": "1", --> removed - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=service_info, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + # "api_enabled": "1", --> removed + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "invalid_discovery_parameters" -async def test_discovery_invalid_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_discovery_invalid_api(hass: HomeAssistant) -> None: """Test discovery detecting invalid_api.""" - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "1", - "path": "/api/not_v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=service_info, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/not_v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unsupported_api_version" -async def test_check_disabled_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "reason"), + [(DisabledError, "api_not_enabled"), (RequestError, "network_error")], +) +async def test_error_flow( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, + reason: str, ) -> None: """Test check detecting disabled api.""" - - def mock_initialize(): - raise DisabledError - - device = get_mock_device() - device.device.side_effect = mock_initialize + mock_homewizardenergy.device.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "127.0.0.1"} + ) assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "api_not_enabled"} + assert result["errors"] == {"base": reason} + + # Recover from error + mock_homewizardenergy.device.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "127.0.0.1"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY -async def test_check_error_handling_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (Exception, "unknown_error"), + (UnsupportedError, "unsupported_api_version"), + ], +) +async def test_abort_flow( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, + reason: str, ) -> None: """Test check detecting error with api.""" - - def mock_initialize(): - raise Exception() - - device = get_mock_device() - device.device.side_effect = mock_initialize + mock_homewizardenergy.device.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -384,146 +323,60 @@ async def test_check_error_handling_api( assert result["type"] == "form" assert result["step_id"] == "user" - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unknown_error" - - -async def test_check_detects_invalid_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test check detecting device endpoint failed fetching data.""" - - def mock_initialize(): - raise UnsupportedError - - device = get_mock_device() - device.device.side_effect = mock_initialize - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unsupported_api_version" - - -async def test_check_requesterror( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test check detecting device endpoint failed fetching data due to a requesterror.""" - - def mock_initialize(): - raise RequestError - - device = get_mock_device() - device.device.side_effect = mock_initialize - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "network_error"} + assert result["reason"] == reason +@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry") async def test_reauth_flow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow while API is enabled.""" - - mock_entry = MockConfigEntry( - domain="homewizard_energy", data={CONF_IP_ADDRESS: "1.2.3.4"} - ) - - mock_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_entry.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" - device = get_mock_device() - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow while API is still disabled.""" - - def mock_initialize(): - raise DisabledError() - - mock_entry = MockConfigEntry( - domain="homewizard_energy", data={CONF_IP_ADDRESS: "1.2.3.4"} - ) - - mock_entry.add_to_hass(hass) + mock_homewizardenergy.device.side_effect = DisabledError + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_entry.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - device = get_mock_device() - device.device.side_effect = mock_initialize - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "api_not_enabled"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "api_not_enabled"} diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 9e9797439b3..71593c69c64 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -1,5 +1,6 @@ """Tests for diagnostics data.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -9,6 +10,9 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize( + "device_fixture", ["device-HWE-P1.json", "device-HWE-SKT.json"] +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 52f214952ab..7dab8cfbb06 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -1,106 +1,62 @@ """Tests for the homewizard component.""" from asyncio import TimeoutError -from unittest.mock import patch +from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, HomeWizardEnergyException +import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from .generator import get_mock_device - from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker async def test_load_unload( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, ) -> None: """Test loading and unloading of integration.""" - - device = get_mock_device() - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy.device.mock_calls) == 1 - await hass.config_entries.async_unload(entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED async def test_load_failed_host_unavailable( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, ) -> None: """Test setup handles unreachable host.""" - - def MockInitialize(): - raise TimeoutError() - - device = get_mock_device() - device.device.side_effect = MockInitialize - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + mock_homewizardenergy.device.side_effect = TimeoutError() + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_load_detect_api_disabled( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, ) -> None: """Test setup detects disabled API.""" - - def MockInitialize(): - raise DisabledError() - - device = get_mock_device() - device.device.side_effect = MockInitialize - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + mock_homewizardenergy.device.side_effect = DisabledError() + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -111,125 +67,54 @@ async def test_load_detect_api_disabled( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id +@pytest.mark.usefixtures("mock_homewizardenergy") async def test_load_removes_reauth_flow( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test setup removes reauth flow when API is enabled.""" - - device = get_mock_device() - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) # Add reauth flow from 'previously' failed init - entry.async_start_reauth(hass) + mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) assert len(flows) == 1 - # Initialize entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED # Flow should be removed flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) assert len(flows) == 0 +@pytest.mark.parametrize( + "exception", + [ + HomeWizardEnergyException, + Exception, + ], +) async def test_load_handles_homewizardenergy_exception( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, + exception: Exception, ) -> None: """Test setup handles exception from API.""" - - def MockInitialize(): - raise HomeWizardEnergyException() - - device = get_mock_device() - device.device.side_effect = MockInitialize - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + mock_homewizardenergy.device.side_effect = exception + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR - - -async def test_load_handles_generic_exception( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant -) -> None: - """Test setup handles global exception.""" - - def MockInitialize(): - raise Exception() - - device = get_mock_device() - device.device.side_effect = MockInitialize - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, + assert mock_config_entry.state in ( + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_ERROR, ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR - - -async def test_load_handles_initialization_error( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant -) -> None: - """Test handles non-exception error.""" - - device = get_mock_device() - device.device = None - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index aa4ab01cfc6..4ae5b2ef22b 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -1,275 +1,88 @@ -"""Test the update coordinator for HomeWizard.""" -from unittest.mock import AsyncMock, patch +"""Test the number entity for HomeWizard.""" +from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError -from homewizard_energy.models import State import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import number +from homeassistant.components.homewizard.const import UPDATE_INTERVAL from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.util.dt as dt_util -from .generator import get_mock_device +from tests.common import async_fire_time_changed -async def test_number_entity_not_loaded_when_not_available( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("device_fixture", ["device-HWE-SKT.json"]) +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_homewizardenergy: MagicMock, + snapshot: SnapshotAssertion, ) -> None: - """Test entity does not load number when brightness is not available.""" + """Test number handles state changes correctly.""" + assert (state := hass.states.get("number.device_status_light_brightness")) + assert snapshot == state - api = get_mock_device() + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("number.product_name_aabbccddeeff_status_light_brightness") - is None - ) - - -async def test_number_loads_entities( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity does load number when brightness is available.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("number.product_name_aabbccddeeff_status_light_brightness") - assert state + # Test unknown handling assert state.state == "100" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Status light brightness" + + mock_homewizardenergy.state.return_value.brightness = None + + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNKNOWN + + # Test service methods + assert len(mock_homewizardenergy.state_set.mock_calls) == 0 + await hass.services.async_call( + number.DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 50, + }, + blocking=True, ) - entry = entity_registry.async_get( - "number.product_name_aabbccddeeff_status_light_brightness" - ) - assert entry - assert entry.unique_id == "aabbccddeeff_status_light_brightness" - assert not entry.disabled + assert len(mock_homewizardenergy.state_set.mock_calls) == 1 + mock_homewizardenergy.state_set.assert_called_with(brightness=127) - -async def test_brightness_level_set( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity turns sets light level.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - def state_set(brightness): - api.state = AsyncMock(return_value=State.from_dict({"brightness": brightness})) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get( - "number.product_name_aabbccddeeff_status_light_brightness" - ).state - == "100" - ) - - # Set level halfway + mock_homewizardenergy.state_set.side_effect = RequestError + with pytest.raises(HomeAssistantError): await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), + ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: 50, }, blocking=True, ) - await hass.async_block_till_done() - assert ( - hass.states.get( - "number.product_name_aabbccddeeff_status_light_brightness" - ).state - == "50" - ) - assert len(api.state_set.mock_calls) == 1 - - # Turn off level + mock_homewizardenergy.state_set.side_effect = DisabledError + with pytest.raises(HomeAssistantError): await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: 0, + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 50, }, blocking=True, ) - - await hass.async_block_till_done() - assert ( - hass.states.get( - "number.product_name_aabbccddeeff_status_light_brightness" - ).state - == "0" - ) - assert len(api.state_set.mock_calls) == 2 - - -async def test_brightness_level_set_catches_requesterror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when RequestError was raised.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - api.state_set = AsyncMock(side_effect=RequestError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Set level halfway - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - number.DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: 50, - }, - blocking=True, - ) - - -async def test_brightness_level_set_catches_disablederror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when DisabledError was raised.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - api.state_set = AsyncMock(side_effect=DisabledError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Set level halfway - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - number.DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: 50, - }, - blocking=True, - ) - - -async def test_brightness_level_set_catches_invalid_value( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises ValueError when value was invalid.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - def state_set(brightness): - api.state = AsyncMock(return_value=State.from_dict({"brightness": brightness})) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - with pytest.raises(ValueError): - await hass.services.async_call( - number.DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: -1, - }, - blocking=True, - ) - - with pytest.raises(ValueError): - await hass.services.async_call( - number.DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: 101, - }, - blocking=True, - ) diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 7ad5140b815..4c0eab22293 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -1,1761 +1,158 @@ -"""Test the update coordinator for HomeWizard.""" -from datetime import timedelta -from unittest.mock import AsyncMock, patch +"""Test sensor entity for HomeWizard.""" + +from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError -from homewizard_energy.models import Data +import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import ( - ATTR_OPTIONS, - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfFrequency, - UnitOfPower, - UnitOfVolume, -) +from homeassistant.components.homewizard.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util -from .generator import get_mock_device - from tests.common import async_fire_time_changed +pytestmark = [ + pytest.mark.usefixtures("init_integration"), +] -async def test_sensor_entity_smr_version( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "data_fixture", "entity_ids"), + [ + ( + "device-HWE-P1.json", + "data-HWE-P1.json", + [ + "sensor.device_dsmr_version", + "sensor.device_smart_meter_model", + "sensor.device_smart_meter_identifier", + "sensor.device_wi_fi_ssid", + "sensor.device_active_tariff", + "sensor.device_wi_fi_strength", + "sensor.device_total_power_import", + "sensor.device_total_power_import_tariff_1", + "sensor.device_total_power_import_tariff_2", + "sensor.device_total_power_import_tariff_3", + "sensor.device_total_power_import_tariff_4", + "sensor.device_total_power_export", + "sensor.device_total_power_export_tariff_1", + "sensor.device_total_power_export_tariff_2", + "sensor.device_total_power_export_tariff_3", + "sensor.device_total_power_export_tariff_4", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_long_power_failures_detected", + "sensor.device_active_average_demand", + "sensor.device_peak_demand_current_month", + "sensor.device_total_gas", + "sensor.device_gas_meter_identifier", + "sensor.device_active_water_usage", + "sensor.device_total_water_usage", + ], + ) + ], +) +async def test_sensors_p1_meter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_ids: list[str], ) -> None: - """Test entity loads smr version.""" + """Test that sensor entity snapshots match.""" + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)) + assert snapshot(name=f"{entity_id}:state") == state - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"smr_version": 50})) + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_dsmr_version") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_dsmr_version") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_smr_version" - assert not entry.disabled - assert state.state == "50" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) DSMR version" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:counter" + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot(name=f"{entity_id}:device-registry") == device_entry -async def test_sensor_entity_meter_model( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize( + "entity_id", + [ + "sensor.device_wi_fi_strength", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + ], +) +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str ) -> None: - """Test entity loads meter model.""" + """Test the disabled by default sensors.""" + assert not hass.states.get(entity_id) - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"meter_model": "Model X"})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_smart_meter_model") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_smart_meter_model" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_meter_model" - assert not entry.disabled - assert state.state == "Model X" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Smart meter model" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:gauge" - - -async def test_sensor_entity_unique_meter_id( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads unique meter id.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"unique_id": "4E47475955"})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_smart_meter_identifier") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_smart_meter_identifier" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_unique_meter_id" - assert not entry.disabled - assert state.state == "NGGYU" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Smart meter identifier" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:alphabetical-variant" - - -async def test_sensor_entity_wifi_ssid( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads wifi ssid.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"wifi_ssid": "My Wifi"})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_wi_fi_ssid") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_wi_fi_ssid") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_wifi_ssid" - assert not entry.disabled - assert state.state == "My Wifi" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Wi-Fi SSID" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:wifi" - - -async def test_sensor_entity_active_tariff( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active_tariff.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_tariff": 2})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_tariff") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_active_tariff") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_tariff" - assert not entry.disabled - assert state.state == "2" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active tariff" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:calendar-clock" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM - assert state.attributes.get(ATTR_OPTIONS) == ["1", "2", "3", "4"] - - -async def test_sensor_entity_wifi_strength( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads wifi strength.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"wifi_strength": 42})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_wi_fi_strength") - assert entry - assert entry.unique_id == "aabbccddeeff_wifi_strength" + assert (entry := entity_registry.async_get(entity_id)) assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION -async def test_sensor_entity_total_power_import_tariff_1_kwh( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize("data_fixture", ["data-HWE-P1-unused-exports.json"]) +@pytest.mark.parametrize( + "entity_id", + [ + "sensor.device_total_power_export", + "sensor.device_total_power_export_tariff_1", + "sensor.device_total_power_export_tariff_2", + "sensor.device_total_power_export_tariff_3", + "sensor.device_total_power_export_tariff_4", + ], +) +async def test_disabled_by_default_sensors_when_unused( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_id: str, ) -> None: - """Test entity loads total power import t1.""" + """Test the disabled by default unused sensors.""" + assert not hass.states.get(entity_id) - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_import_t1_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_power_import_t1_kwh" - assert not entry.disabled - assert state.state == "1234.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total power import tariff 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_total_power_import_tariff_2_kwh( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total power import t2.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_import_t2_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_2" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_power_import_t2_kwh" - assert not entry.disabled - assert state.state == "1234.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total power import tariff 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_total_power_export_tariff_1_kwh( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total power export t1.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_export_t1_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_1" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_power_export_t1_kwh" - assert not entry.disabled - assert state.state == "1234.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total power export tariff 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_total_power_export_tariff_2_kwh( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total power export t2.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_export_t2_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_2" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_power_export_t2_kwh" - assert not entry.disabled - assert state.state == "1234.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total power export tariff 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_power( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_power_w": 123.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_power") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_active_power") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_w" - assert not entry.disabled - assert state.state == "123.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active power" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_power_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_power_l1_w": 123.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_phase_1") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_l1_w" - assert not entry.disabled - assert state.state == "123.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active power phase 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_power_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_power_l2_w": 456.456})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_phase_2") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_l2_w" - assert not entry.disabled - assert state.state == "456.456" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active power phase 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_power_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_power_l3_w": 789.789})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_phase_3") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_3" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_l3_w" - assert not entry.disabled - assert state.state == "789.789" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active power phase 3" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_total_gas( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total gas.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"total_gas_m3": 50})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_total_gas") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_total_gas") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_gas_m3" - assert not entry.disabled - assert state.state == "50" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total gas" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_unique_gas_meter_id( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads unique gas meter id.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"gas_unique_id": "4E47475955"})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_gas_meter_identifier") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_gas_meter_identifier" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_gas_unique_id" - assert not entry.disabled - assert state.state == "NGGYU" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Gas meter identifier" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:alphabetical-variant" - - -async def test_sensor_entity_active_voltage_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active voltage l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_voltage_l1_v": 230.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_1" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_voltage_l1_v" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_1" - ) - assert state - assert state.state == "230.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active voltage phase 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_voltage_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active voltage l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_voltage_l2_v": 230.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_2" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_voltage_l2_v" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_2" - ) - assert state - assert state.state == "230.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active voltage phase 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_voltage_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active voltage l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_voltage_l3_v": 230.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_3" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_voltage_l3_v" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_3" - ) - assert state - assert state.state == "230.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active voltage phase 3" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_current_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active current l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_current_l1_a": 12.34})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_current_phase_1" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_current_l1_a" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_current_phase_1" - ) - assert state - assert state.state == "12.34" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active current phase 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricCurrent.AMPERE - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_current_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active current l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_current_l2_a": 12.34})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_current_phase_2" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_current_l2_a" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_current_phase_2" - ) - assert state - assert state.state == "12.34" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active current phase 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricCurrent.AMPERE - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_current_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active current l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_current_l3_a": 12.34})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_current_phase_3" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_current_l3_a" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_current_phase_3" - ) - assert state - assert state.state == "12.34" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active current phase 3" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricCurrent.AMPERE - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_frequency( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active frequency.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_frequency_hz": 50.12})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_frequency" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_frequency_hz" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_frequency") - assert state - assert state.state == "50.12" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active frequency" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfFrequency.HERTZ - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.FREQUENCY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_voltage_sag_count_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_sag_count_l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_sag_l1_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_1" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_sag_l1_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage sags detected phase 1" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_sag_count_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_sag_count_l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_sag_l2_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_2" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_sag_l2_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage sags detected phase 2" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_sag_count_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_sag_count_l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_sag_l3_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_3" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_3" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_sag_l3_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage sags detected phase 3" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_swell_count_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_swell_count_l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_swell_l1_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_1" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_swell_l1_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage swells detected phase 1" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_swell_count_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_swell_count_l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_swell_l2_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_2" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_swell_l2_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage swells detected phase 2" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_swell_count_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_swell_count_l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_swell_l3_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_3" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_3" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_swell_l3_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage swells detected phase 3" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_any_power_fail_count( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads any power fail count.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"any_power_fail_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_power_failures_detected") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_power_failures_detected" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_any_power_fail_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Power failures detected" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_long_power_fail_count( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads long power fail count.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"long_power_fail_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_long_power_failures_detected" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_long_power_failures_detected" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_long_power_fail_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Long power failures detected" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_active_power_average( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power average.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"active_power_average_w": 123.456}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_average_demand") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_average_demand" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_average_w" - assert not entry.disabled - assert state.state == "123.456" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active average demand" - ) - - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_monthly_power_peak( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads monthly power peak.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"montly_power_peak_w": 1234.456})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_peak_demand_current_month" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_peak_demand_current_month" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_monthly_power_peak_w" - assert not entry.disabled - assert state.state == "1234.456" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Peak demand current month" - ) - - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_liters( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active liters (watermeter).""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_liter_lpm": 12.345})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_water_usage") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_water_usage" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_liter_lpm" - assert not entry.disabled - assert state.state == "12.345" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active water usage" - ) - - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "l/min" - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:water" - - -async def test_sensor_entity_total_liters( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total liters (watermeter).""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"total_liter_m3": 1234.567})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_total_water_usage") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_water_usage" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_liter_m3" - assert not entry.disabled - assert state.state == "1234.567" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total water usage" - ) - - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER - assert state.attributes.get(ATTR_ICON) == "mdi:gauge" - - -async def test_sensor_entity_disabled_when_null( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test sensor disables data with null by default.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict( - {"active_power_l2_w": None, "active_power_l3_w": None, "total_gas_m3": None} - ) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_2" - ) - assert entry is None - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_3" - ) - assert entry is None - - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_total_gas") - assert entry is None - - -async def test_sensor_entity_export_disabled_when_unused( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test sensor disables export if value is 0.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict( - { - "total_power_export_kwh": 0, - "total_power_export_t1_kwh": 0, - "total_power_export_t2_kwh": 0, - "total_power_export_t3_kwh": 0, - "total_power_export_t4_kwh": 0, - } - ) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export" - ) - assert entry - assert entry.disabled - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_1" - ) - assert entry - assert entry.disabled - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_2" - ) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION +@pytest.mark.parametrize("exception", [RequestError, DisabledError]) async def test_sensors_unreachable( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, ) -> None: - """Test sensor handles api unreachable.""" + """Test sensor handles API unreachable.""" + assert (state := hass.states.get("sensor.device_total_power_import_tariff_1")) + assert state.state == "10830.511" - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_import_t1_kwh": 1234.123}) - ) + mock_homewizardenergy.data.side_effect = exception + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - utcnow = dt_util.utcnow() # Time after the integration is setup - - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "1234.123" - ) - - api.data.side_effect = RequestError - async_fire_time_changed(hass, utcnow + timedelta(seconds=5)) - await hass.async_block_till_done() - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "unavailable" - ) - - api.data.side_effect = None - async_fire_time_changed(hass, utcnow + timedelta(seconds=10)) - await hass.async_block_till_done() - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "1234.123" - ) - - -async def test_api_disabled( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test sensor handles api unreachable.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_import_t1_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - utcnow = dt_util.utcnow() # Time after the integration is setup - - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "1234.123" - ) - - api.data.side_effect = DisabledError - async_fire_time_changed(hass, utcnow + timedelta(seconds=5)) - await hass.async_block_till_done() - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "unavailable" - ) + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 6a2623e964f..4c5e1dda6a0 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -1,545 +1,146 @@ -"""Test the update coordinator for HomeWizard.""" -from unittest.mock import AsyncMock, patch +"""Test the switch entity for HomeWizard.""" +from unittest.mock import MagicMock -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.models import State, System +from homewizard_energy import UnsupportedError +from homewizard_energy.errors import DisabledError, RequestError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch -from homeassistant.components.switch import SwitchDeviceClass +from homeassistant.components.homewizard.const import UPDATE_INTERVAL from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, - STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.util.dt as dt_util -from .generator import get_mock_device +from tests.common import async_fire_time_changed + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), +] -async def test_switch_entity_not_loaded_when_not_available( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize("device_fixture", ["device-HWE-SKT.json"]) +@pytest.mark.parametrize( + ("entity_id", "method", "parameter"), + [ + ("switch.device", "state_set", "power_on"), + ("switch.device_switch_lock", "state_set", "switch_lock"), + ("switch.device_cloud_connection", "system_set", "cloud_enabled"), + ], +) +async def test_switch_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_homewizardenergy: MagicMock, + snapshot: SnapshotAssertion, + entity_id: str, + method: str, + parameter: str, ) -> None: - """Test entity loads smr version.""" + """Test that switch handles state changes correctly.""" + assert (state := hass.states.get(entity_id)) + assert snapshot == state - api = get_mock_device() + assert (entity_entry := entity_registry.async_get(entity_id)) + assert snapshot == entity_entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + mocked_method = getattr(mock_homewizardenergy, method) - state_power_on = hass.states.get("sensor.product_name_aabbccddeeff") - state_switch_lock = hass.states.get("sensor.product_name_aabbccddeeff_switch_lock") - - assert state_power_on is None - assert state_switch_lock is None - - -async def test_switch_loads_entities( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads smr version.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) + # Turn power_on on + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) + assert len(mocked_method.mock_calls) == 1 + mocked_method.assert_called_with(**{parameter: True}) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state_power_on = hass.states.get("switch.product_name_aabbccddeeff") - entry_power_on = entity_registry.async_get("switch.product_name_aabbccddeeff") - assert state_power_on - assert entry_power_on - assert entry_power_on.unique_id == "aabbccddeeff_power_on" - assert not entry_power_on.disabled - assert state_power_on.state == STATE_OFF - assert ( - state_power_on.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff)" - ) - assert state_power_on.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET - assert ATTR_ICON not in state_power_on.attributes - - state_switch_lock = hass.states.get("switch.product_name_aabbccddeeff_switch_lock") - entry_switch_lock = entity_registry.async_get( - "switch.product_name_aabbccddeeff_switch_lock" + # Turn power_on off + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) - assert state_switch_lock - assert entry_switch_lock - assert entry_switch_lock.unique_id == "aabbccddeeff_switch_lock" - assert not entry_switch_lock.disabled - assert state_switch_lock.state == STATE_OFF - assert ( - state_switch_lock.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Switch lock" - ) - assert state_switch_lock.attributes.get(ATTR_ICON) == "mdi:lock-open" - assert ATTR_DEVICE_CLASS not in state_switch_lock.attributes + assert len(mocked_method.mock_calls) == 2 + mocked_method.assert_called_with(**{parameter: False}) + # Test request error handling + mocked_method.side_effect = RequestError -async def test_switch_power_on_off( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity turns switch on and off.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) - ) - - def state_set(power_on): - api.state = AsyncMock( - return_value=State.from_dict({"power_on": power_on, "switch_lock": False}) - ) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_OFF - - # Turn power_on on + with pytest.raises(HomeAssistantError): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert len(api.state_set.mock_calls) == 1 - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_ON - - # Turn power_on off + with pytest.raises(HomeAssistantError): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_OFF - assert len(api.state_set.mock_calls) == 2 + # Test disabled error handling + mocked_method.side_effect = DisabledError - -async def test_switch_lock_power_on_off( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity turns switch on and off.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) - ) - - def state_set(switch_lock): - api.state = AsyncMock( - return_value=State.from_dict({"power_on": True, "switch_lock": switch_lock}) - ) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_OFF - ) - - # Turn power_on on + with pytest.raises(HomeAssistantError): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert len(api.state_set.mock_calls) == 1 - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_ON - ) - - # Turn power_on off + with pytest.raises(HomeAssistantError): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_OFF - ) - assert len(api.state_set.mock_calls) == 2 - -async def test_switch_lock_sets_power_on_unavailable( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize("device_fixture", ["device-HWE-SKT.json"]) +@pytest.mark.parametrize("exception", [RequestError, DisabledError, UnsupportedError]) +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("switch.device", "state"), + ("switch.device_switch_lock", "state"), + ("switch.device_cloud_connection", "system"), + ], +) +async def test_switch_unreachable( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, + entity_id: str, + method: str, ) -> None: - """Test entity turns switch on and off.""" + """Test that unreachable devices are marked as unavailable.""" + mocked_method = getattr(mock_homewizardenergy, method) + mocked_method.side_effect = exception + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": True, "switch_lock": False}) - ) - - def state_set(switch_lock): - api.state = AsyncMock( - return_value=State.from_dict({"power_on": True, "switch_lock": switch_lock}) - ) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_ON - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_OFF - ) - - # Turn power_on on - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - await hass.async_block_till_done() - assert len(api.state_set.mock_calls) == 1 - assert ( - hass.states.get("switch.product_name_aabbccddeeff").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_ON - ) - - # Turn power_on off - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - await hass.async_block_till_done() - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_ON - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_OFF - ) - assert len(api.state_set.mock_calls) == 2 - - -async def test_cloud_connection_on_off( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity turns switch on and off.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - api.system = AsyncMock(return_value=System.from_dict({"cloud_enabled": False})) - - def system_set(cloud_enabled): - api.system = AsyncMock( - return_value=System.from_dict({"cloud_enabled": cloud_enabled}) - ) - - api.system_set = AsyncMock(side_effect=system_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("switch.product_name_aabbccddeeff_cloud_connection").state - == STATE_OFF - ) - - # Enable cloud - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - await hass.async_block_till_done() - assert len(api.system_set.mock_calls) == 1 - assert ( - hass.states.get("switch.product_name_aabbccddeeff_cloud_connection").state - == STATE_ON - ) - - # Disable cloud - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - await hass.async_block_till_done() - assert ( - hass.states.get("switch.product_name_aabbccddeeff_cloud_connection").state - == STATE_OFF - ) - assert len(api.system_set.mock_calls) == 2 - - -async def test_switch_handles_requesterror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when RequestError was raised.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) - ) - api.system = AsyncMock(return_value=System.from_dict({"cloud_enabled": False})) - - api.state_set = AsyncMock(side_effect=RequestError()) - api.system_set = AsyncMock(side_effect=RequestError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Power on toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - # Switch Lock toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - # Disable Cloud toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - -async def test_switch_handles_disablederror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when Disabled was raised.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) - ) - api.system = AsyncMock(return_value=System.from_dict({"cloud_enabled": False})) - - api.state_set = AsyncMock(side_effect=DisabledError()) - api.system_set = AsyncMock(side_effect=DisabledError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Power on toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - # Switch Lock toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - # Disable Cloud toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - -async def test_switch_handles_unsupportedrrror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when Disabled was raised.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - api.state = AsyncMock(side_effect=UnsupportedError()) - api.system = AsyncMock(side_effect=UnsupportedError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("switch.product_name_aabbccddeeff_cloud_connection").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("switch.product_name_aabbccddeeff").state - == STATE_UNAVAILABLE - ) + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE From 4ed3676a56ad6bdd441353f3d817d1666dbf6bb9 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 30 Oct 2023 09:57:24 -0400 Subject: [PATCH 090/982] Bump pyschlage to 2023.10.0 (#103065) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 3568692c6ca..f474f739904 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.9.1"] + "requirements": ["pyschlage==2023.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5bade76c2a4..d7cab339982 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2004,7 +2004,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.9.1 +pyschlage==2023.10.0 # homeassistant.components.sensibo pysensibo==1.0.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a7040e2691..00be2dc1dfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1511,7 +1511,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.9.1 +pyschlage==2023.10.0 # homeassistant.components.sensibo pysensibo==1.0.35 From 92ec525de1d11659f4fc0f28ec28090100a482f2 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 30 Oct 2023 10:16:41 -0400 Subject: [PATCH 091/982] Add retry before unavailable to Honeywell (#101702) Co-authored-by: Robert Resch --- homeassistant/components/honeywell/climate.py | 13 ++++++++-- homeassistant/components/honeywell/const.py | 1 + tests/components/honeywell/test_climate.py | 26 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 63d05135d5d..ab23c878c15 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -38,6 +38,7 @@ from .const import ( CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE, DOMAIN, + RETRY, ) ATTR_FAN_ACTION = "fan_action" @@ -155,6 +156,7 @@ class HoneywellUSThermostat(ClimateEntity): self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False + self._retry = 0 self._attr_unique_id = device.deviceid @@ -483,21 +485,28 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() self._attr_available = True + self._retry = 0 + except UnauthorizedError: try: await self._data.client.login() await self._device.refresh() self._attr_available = True + self._retry = 0 except ( SomeComfortError, ClientConnectionError, asyncio.TimeoutError, ): - self._attr_available = False + self._retry += 1 + if self._retry > RETRY: + self._attr_available = False except (ClientConnectionError, asyncio.TimeoutError): - self._attr_available = False + self._retry += 1 + if self._retry > RETRY: + self._attr_available = False except UnexpectedResponse: pass diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py index d5153a69f65..32846563c44 100644 --- a/homeassistant/components/honeywell/const.py +++ b/homeassistant/components/honeywell/const.py @@ -10,3 +10,4 @@ DEFAULT_HEAT_AWAY_TEMPERATURE = 61 CONF_DEV_ID = "thermostat" CONF_LOC_ID = "location" _LOGGER = logging.getLogger(__name__) +RETRY = 3 diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 7bd76cb8522..53cb70475c9 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -28,7 +28,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.honeywell.climate import SCAN_INTERVAL +from homeassistant.components.honeywell.climate import RETRY, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -1083,6 +1083,17 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" + # Due to server instability, only mark entity unavailable after RETRY update attempts + for _ in range(RETRY): + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + async_fire_time_changed( hass, utcnow() + SCAN_INTERVAL, @@ -1126,7 +1137,6 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" - # "reload integration" test device.refresh.side_effect = aiosomecomfort.SomeComfortError client.login.side_effect = aiosomecomfort.AuthError async_fire_time_changed( @@ -1139,6 +1149,18 @@ async def test_async_update_errors( assert state.state == "off" device.refresh.side_effect = ClientConnectionError + + # Due to server instability, only mark entity unavailable after RETRY update attempts + for _ in range(RETRY): + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + async_fire_time_changed( hass, utcnow() + SCAN_INTERVAL, From f160fa4bc3da033d0942c4d5a33cac1081540eab Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 30 Oct 2023 10:18:59 -0400 Subject: [PATCH 092/982] Make Hydrawise initialize data immediately (#101936) --- .../components/hydrawise/__init__.py | 26 +++----------- .../components/hydrawise/binary_sensor.py | 11 +++--- homeassistant/components/hydrawise/entity.py | 12 +++++++ homeassistant/components/hydrawise/sensor.py | 12 +++---- homeassistant/components/hydrawise/switch.py | 10 ++---- .../hydrawise/test_binary_sensor.py | 5 --- tests/components/hydrawise/test_init.py | 34 ++++++++----------- tests/components/hydrawise/test_sensor.py | 12 ++----- tests/components/hydrawise/test_switch.py | 9 +---- 9 files changed, 45 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index bc3c62cfb9f..ddff1954eb3 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -2,7 +2,6 @@ from pydrawise import legacy -from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -13,11 +12,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( @@ -53,24 +51,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" access_token = config_entry.data[CONF_API_KEY] - try: - hydrawise = await hass.async_add_executor_job( - legacy.LegacyHydrawise, access_token - ) - except (ConnectTimeout, HTTPError) as ex: - LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - raise ConfigEntryNotReady( - f"Unable to connect to Hydrawise cloud service: {ex}" - ) from ex - - hass.data.setdefault(DOMAIN, {})[ - config_entry.entry_id - ] = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) - if not hydrawise.controller_info or not hydrawise.controller_status: - raise ConfigEntryNotReady("Hydrawise data not loaded") - - # NOTE: We don't need to call async_config_entry_first_refresh() because - # data is fetched when the Hydrawiser object is instantiated. + hydrawise = legacy.LegacyHydrawise(access_token, load_on_init=False) + coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 30096a9bf97..1953e413672 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -12,12 +12,12 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -95,13 +95,10 @@ async def async_setup_entry( class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and updates the state.""" - LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) + def _update_attrs(self) -> None: + """Update state attributes.""" if self.entity_description.key == "status": self._attr_is_on = self.coordinator.last_update_success elif self.entity_description.key == "is_watering": relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] self._attr_is_on = relay_data["timestr"] == "Now" - super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index c3f295e1c4d..38fde322673 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,3 +37,14 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): name=data["name"], manufacturer=MANUFACTURER, ) + self._update_attrs() + + def _update_attrs(self) -> None: + """Update state attributes.""" + return # pragma: no cover + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and updates the state.""" + self._update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index ef98ce99bfb..369e952c1be 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -11,13 +11,13 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -82,10 +82,8 @@ async def async_setup_entry( class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and updates the states.""" - LOGGER.debug("Updating Hydrawise sensor: %s", self.name) + def _update_attrs(self) -> None: + """Update state attributes.""" relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": @@ -94,8 +92,6 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): self._attr_native_value = 0 else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) - LOGGER.debug("New cycle time: %s", next_cycle) self._attr_native_value = dt_util.utc_from_timestamp( dt_util.as_timestamp(dt_util.now()) + next_cycle ) - super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index d1ea0233145..caaefd7aa26 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -23,7 +23,6 @@ from .const import ( CONF_WATERING_TIME, DEFAULT_WATERING_TIME, DOMAIN, - LOGGER, ) from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -124,14 +123,11 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): elif self.entity_description.key == "auto_watering": self.coordinator.api.suspend_zone(365, zone_number) - @callback - def _handle_coordinator_update(self) -> None: - """Update device state.""" + def _update_attrs(self) -> None: + """Update state attributes.""" zone_number = self.data["relay"] - LOGGER.debug("Updating Hydrawise switch: %s", self.name) timestr = self.coordinator.api.relays_by_zone_number[zone_number]["timestr"] if self.entity_description.key == "manual_watering": self._attr_is_on = timestr == "Now" elif self.entity_description.key == "auto_watering": self._attr_is_on = timestr not in {"", "Now"} - super()._handle_coordinator_update() diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py index ab88c5fb750..c60f4392f1e 100644 --- a/tests/components/hydrawise/test_binary_sensor.py +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -17,11 +17,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test binary_sensor states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - connectivity = hass.states.get("binary_sensor.home_controller_connectivity") assert connectivity is not None assert connectivity.state == "on" diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 87c158ec0b9..79cea94d479 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -1,6 +1,6 @@ """Tests for the Hydrawise integration.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock from requests.exceptions import HTTPError @@ -15,6 +15,7 @@ from tests.common import MockConfigEntry async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) -> None: """Test that setup with a YAML config triggers an import and warning.""" + mock_pydrawise.update_controller_info.return_value = True mock_pydrawise.customer_id = 12345 mock_pydrawise.status = "unknown" config = {"hydrawise": {CONF_ACCESS_TOKEN: "_access-token_"}} @@ -29,29 +30,22 @@ async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) - async def test_connect_retry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock ) -> None: """Test that a connection error triggers a retry.""" - with patch("pydrawise.legacy.LegacyHydrawise") as mock_api: - mock_api.side_effect = HTTPError - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_api.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_pydrawise.update_controller_info.side_effect = HTTPError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_setup_no_data( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock ) -> None: """Test that no data from the API triggers a retry.""" - with patch("pydrawise.legacy.LegacyHydrawise") as mock_api: - mock_api.return_value.controller_info = {} - mock_api.return_value.controller_status = None - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_api.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_pydrawise.update_controller_info.return_value = False + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py index b7c60f333f4..c6d3fecab65 100644 --- a/tests/components/hydrawise/test_sensor.py +++ b/tests/components/hydrawise/test_sensor.py @@ -1,14 +1,11 @@ """Test Hydrawise sensor.""" -from datetime import timedelta - from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.hydrawise.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry @pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") @@ -18,11 +15,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test sensor states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - watering_time1 = hass.states.get("sensor.zone_one_watering_time") assert watering_time1 is not None assert watering_time1.state == "0" @@ -33,4 +25,4 @@ async def test_states( next_cycle = hass.states.get("sensor.zone_one_next_cycle") assert next_cycle is not None - assert next_cycle.state == "2023-10-04T19:52:27+00:00" + assert next_cycle.state == "2023-10-04T19:49:57+00:00" diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index 615a336ee5f..39d789f4cf9 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -1,16 +1,14 @@ """Test Hydrawise switch.""" -from datetime import timedelta from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.hydrawise.const import SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry async def test_states( @@ -19,11 +17,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test switch states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - watering1 = hass.states.get("switch.zone_one_manual_watering") assert watering1 is not None assert watering1.state == "off" From 953d5e00806c5e6f12319dcd42572816ad11ba36 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Oct 2023 15:47:08 +0100 Subject: [PATCH 093/982] Add 2 properties to Withings diagnostics (#103067) --- homeassistant/components/withings/diagnostics.py | 2 ++ tests/components/withings/snapshots/test_diagnostics.ambr | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index 7ed9f6ce2c9..31c9ffef569 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -33,4 +33,6 @@ async def async_get_config_entry_diagnostics( "webhooks_connected": withings_data.measurement_coordinator.webhooks_connected, "received_measurements": list(withings_data.measurement_coordinator.data), "received_sleep_data": withings_data.sleep_coordinator.data is not None, + "received_workout_data": withings_data.workout_coordinator.data is not None, + "received_activity_data": withings_data.activity_coordinator.data is not None, } diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index 3b6a5390bd6..f9b4a1d9bba 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -3,6 +3,7 @@ dict({ 'has_cloudhooks': True, 'has_valid_external_webhook_url': True, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -26,6 +27,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': True, }) # --- @@ -33,6 +35,7 @@ dict({ 'has_cloudhooks': False, 'has_valid_external_webhook_url': False, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -56,6 +59,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': False, }) # --- @@ -63,6 +67,7 @@ dict({ 'has_cloudhooks': False, 'has_valid_external_webhook_url': True, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -86,6 +91,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': True, }) # --- From 7b6910882e39cf5e0afeb91d04c66b58b0d7a235 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 30 Oct 2023 15:57:00 +0100 Subject: [PATCH 094/982] Use correct config entry field to update when IP changes in loqed (#103051) --- homeassistant/components/loqed/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 911ccb0ff5b..1c76f480529 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import webhook from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_NAME, CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -95,7 +95,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Check if already exists await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) - self._abort_if_unique_id_configured({CONF_HOST: host}) + self._abort_if_unique_id_configured({"bridge_ip": host}) return await self.async_step_user() From 74b19564adbb91ebdf111426d12ceead95ef5386 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 30 Oct 2023 08:08:51 -0700 Subject: [PATCH 095/982] Fix Opower not refreshing statistics when there are no forecast entities (#103058) Ensure _insert_statistics is periodically called --- homeassistant/components/opower/coordinator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 5ce35e949af..239f23e7523 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -23,7 +23,7 @@ from homeassistant.components.recorder.statistics import ( statistics_during_period, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -58,6 +58,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): entry_data.get(CONF_TOTP_SECRET), ) + @callback + def _dummy_listener() -> None: + pass + + # Force the coordinator to periodically update by registering at least one listener. + # Needed when the _async_update_data below returns {} for utilities that don't provide + # forecast, which results to no sensors added, no registered listeners, and thus + # _async_update_data not periodically getting called which is needed for _insert_statistics. + self.async_add_listener(_dummy_listener) + async def _async_update_data( self, ) -> dict[str, Forecast]: @@ -71,6 +81,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise ConfigEntryAuthFailed from err forecasts: list[Forecast] = await self.api.async_get_forecast() _LOGGER.debug("Updating sensor data with: %s", forecasts) + # Because Opower provides historical usage/cost with a delay of a couple of days + # we need to insert data into statistics. await self._insert_statistics() return {forecast.account.utility_account_id: forecast for forecast in forecasts} From 78e316aa7e24eaa0a5c32c494d5bd230e7a2d518 Mon Sep 17 00:00:00 2001 From: Nortonko <52453201+Nortonko@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:32:24 +0100 Subject: [PATCH 096/982] Bump python-androidtv to 0.0.73 (#102999) * Update manifest.json Bump python-androidtv to version 0.0.73 * bump androidtv 0.0.73 * bump androidtv 0.0.73 --- homeassistant/components/androidtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index b8c020e6e1e..2d0b062c750 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -9,7 +9,7 @@ "loggers": ["adb_shell", "androidtv", "pure_python_adb"], "requirements": [ "adb-shell[async]==0.4.4", - "androidtv[async]==0.0.72", + "androidtv[async]==0.0.73", "pure-python-adb[async]==0.3.0.dev0" ] } diff --git a/requirements_all.txt b/requirements_all.txt index d7cab339982..176dd0eb52b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -414,7 +414,7 @@ amberelectric==1.0.4 amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.72 +androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00be2dc1dfe..0da785c25d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -383,7 +383,7 @@ airtouch4pyapi==1.0.5 amberelectric==1.0.4 # homeassistant.components.androidtv -androidtv[async]==0.0.72 +androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 From bdfb138b096a58f630438beb93d4fc1cd04a8a39 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:47:33 +0100 Subject: [PATCH 097/982] Update PyViCare to v2.28.1 for ViCare integration (#103064) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 418172975d8..e8bc4178073 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.25.0"] + "requirements": ["PyViCare==2.28.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 176dd0eb52b..81c225408bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -113,7 +113,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.25.0 +PyViCare==2.28.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0da785c25d5..ea4413813ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.25.0 +PyViCare==2.28.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From cb0517d20e2d03c5d8836d3e0fd9b317e71f5702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 30 Oct 2023 18:54:50 +0100 Subject: [PATCH 098/982] Update AEMET-OpenData to v0.4.6 (#102996) --- homeassistant/components/aemet/__init__.py | 7 +++---- homeassistant/components/aemet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aemet/test_init.py | 4 ++-- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 13e636b2196..843693d2dc3 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,9 +1,8 @@ """The AEMET OpenData component.""" -import asyncio import logging -from aemet_opendata.exceptions import TownNotFound +from aemet_opendata.exceptions import AemetError, TownNotFound from aemet_opendata.interface import AEMET, ConnectionOptions from homeassistant.config_entries import ConfigEntry @@ -39,8 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except TownNotFound as err: _LOGGER.error(err) return False - except asyncio.TimeoutError as err: - raise ConfigEntryNotReady("AEMET OpenData API timed out") from err + except AemetError as err: + raise ConfigEntryNotReady(err) from err weather_coordinator = WeatherUpdateCoordinator(hass, aemet) await weather_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 74d53cc117a..544931b50b5 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.4.5"] + "requirements": ["AEMET-OpenData==0.4.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 81c225408bd..aa9a1567d9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2,7 +2,7 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.5 +AEMET-OpenData==0.4.6 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea4413813ab..d8885ba51b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.5 +AEMET-OpenData==0.4.6 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index 9389acf07c9..7a4f73dc62b 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -1,7 +1,7 @@ """Define tests for the AEMET OpenData init.""" -import asyncio from unittest.mock import patch +from aemet_opendata.exceptions import AemetTimeout from freezegun.api import FrozenDateTimeFactory from homeassistant.components.aemet.const import DOMAIN @@ -83,7 +83,7 @@ async def test_init_api_timeout( freezer.move_to("2021-01-09 12:00:00+00:00") with patch( "homeassistant.components.aemet.AEMET.api_call", - side_effect=asyncio.TimeoutError, + side_effect=AemetTimeout, ): config_entry = MockConfigEntry( domain=DOMAIN, From 0f72495a7d58ad5bebb9041a8115e8bd2b0c7172 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 30 Oct 2023 19:09:23 +0100 Subject: [PATCH 099/982] Rename power to energy in HomeWizard (#102948) --- .../components/homewizard/manifest.json | 2 +- homeassistant/components/homewizard/sensor.py | 70 +- .../components/homewizard/strings.json | 40 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 60 +- .../homewizard/snapshots/test_sensor.ambr | 800 ++++++++++++++++++ tests/components/homewizard/test_sensor.py | 32 +- 8 files changed, 904 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 96507cb26e4..5fce3f2ea2d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==2.1.2"], + "requirements": ["python-homewizard-energy==3.0.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index a342e11bea0..c98a8fa05b4 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -109,98 +109,98 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", - translation_key="total_power_import_kwh", + translation_key="total_energy_import_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_kwh is not None, - value_fn=lambda data: data.total_power_import_kwh or None, + has_fn=lambda data: data.total_energy_import_kwh is not None, + value_fn=lambda data: data.total_energy_import_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", - translation_key="total_power_import_t1_kwh", + translation_key="total_energy_import_t1_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_t1_kwh is not None, - value_fn=lambda data: data.total_power_import_t1_kwh or None, + has_fn=lambda data: data.total_energy_import_t1_kwh is not None, + value_fn=lambda data: data.total_energy_import_t1_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", - translation_key="total_power_import_t2_kwh", + translation_key="total_energy_import_t2_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_t2_kwh is not None, - value_fn=lambda data: data.total_power_import_t2_kwh or None, + has_fn=lambda data: data.total_energy_import_t2_kwh is not None, + value_fn=lambda data: data.total_energy_import_t2_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", - translation_key="total_power_import_t3_kwh", + translation_key="total_energy_import_t3_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_t3_kwh is not None, - value_fn=lambda data: data.total_power_import_t3_kwh or None, + has_fn=lambda data: data.total_energy_import_t3_kwh is not None, + value_fn=lambda data: data.total_energy_import_t3_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", - translation_key="total_power_import_t4_kwh", + translation_key="total_energy_import_t4_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_t4_kwh is not None, - value_fn=lambda data: data.total_power_import_t4_kwh or None, + has_fn=lambda data: data.total_energy_import_t4_kwh is not None, + value_fn=lambda data: data.total_energy_import_t4_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", - translation_key="total_power_export_kwh", + translation_key="total_energy_export_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_kwh is not None, - enabled_fn=lambda data: data.total_power_export_kwh != 0, - value_fn=lambda data: data.total_power_export_kwh or None, + has_fn=lambda data: data.total_energy_export_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_kwh != 0, + value_fn=lambda data: data.total_energy_export_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", - translation_key="total_power_export_t1_kwh", + translation_key="total_energy_export_t1_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_t1_kwh is not None, - enabled_fn=lambda data: data.total_power_export_t1_kwh != 0, - value_fn=lambda data: data.total_power_export_t1_kwh or None, + has_fn=lambda data: data.total_energy_export_t1_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_t1_kwh != 0, + value_fn=lambda data: data.total_energy_export_t1_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", - translation_key="total_power_export_t2_kwh", + translation_key="total_energy_export_t2_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_t2_kwh is not None, - enabled_fn=lambda data: data.total_power_export_t2_kwh != 0, - value_fn=lambda data: data.total_power_export_t2_kwh or None, + has_fn=lambda data: data.total_energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_t2_kwh != 0, + value_fn=lambda data: data.total_energy_export_t2_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", - translation_key="total_power_export_t3_kwh", + translation_key="total_energy_export_t3_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_t3_kwh is not None, - enabled_fn=lambda data: data.total_power_export_t3_kwh != 0, - value_fn=lambda data: data.total_power_export_t3_kwh or None, + has_fn=lambda data: data.total_energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_t3_kwh != 0, + value_fn=lambda data: data.total_energy_export_t3_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", - translation_key="total_power_export_t4_kwh", + translation_key="total_energy_export_t4_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_t4_kwh is not None, - enabled_fn=lambda data: data.total_power_export_t4_kwh != 0, - value_fn=lambda data: data.total_power_export_t4_kwh or None, + has_fn=lambda data: data.total_energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_t4_kwh != 0, + value_fn=lambda data: data.total_energy_export_t4_kwh or None, ), HomeWizardSensorEntityDescription( key="active_power_w", diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 7bb4b16c710..3bc55b3c848 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -53,35 +53,35 @@ "wifi_strength": { "name": "Wi-Fi strength" }, - "total_power_import_kwh": { - "name": "Total power import" + "total_energy_import_kwh": { + "name": "Total energy import" }, - "total_power_import_t1_kwh": { - "name": "Total power import tariff 1" + "total_energy_import_t1_kwh": { + "name": "Total energy import tariff 1" }, - "total_power_import_t2_kwh": { - "name": "Total power import tariff 2" + "total_energy_import_t2_kwh": { + "name": "Total energy import tariff 2" }, - "total_power_import_t3_kwh": { - "name": "Total power import tariff 3" + "total_energy_import_t3_kwh": { + "name": "Total energy import tariff 3" }, - "total_power_import_t4_kwh": { - "name": "Total power import tariff 4" + "total_energy_import_t4_kwh": { + "name": "Total energy import tariff 4" }, - "total_power_export_kwh": { - "name": "Total power export" + "total_energy_export_kwh": { + "name": "Total energy export" }, - "total_power_export_t1_kwh": { - "name": "Total power export tariff 1" + "total_energy_export_t1_kwh": { + "name": "Total energy export tariff 1" }, - "total_power_export_t2_kwh": { - "name": "Total power export tariff 2" + "total_energy_export_t2_kwh": { + "name": "Total energy export tariff 2" }, - "total_power_export_t3_kwh": { - "name": "Total power export tariff 3" + "total_energy_export_t3_kwh": { + "name": "Total energy export tariff 3" }, - "total_power_export_t4_kwh": { - "name": "Total power export tariff 4" + "total_energy_export_t4_kwh": { + "name": "Total energy export tariff 4" }, "active_power_w": { "name": "Active power" diff --git a/requirements_all.txt b/requirements_all.txt index aa9a1567d9c..a45a933f8d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2126,7 +2126,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==2.1.2 +python-homewizard-energy==3.0.0 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8885ba51b7..e1ebc094650 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1588,7 +1588,7 @@ python-ecobee-api==0.2.17 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==2.1.2 +python-homewizard-energy==3.0.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 42697e882fa..2a7f61fcf82 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -28,16 +28,16 @@ 'smr_version': 50, 'total_gas_m3': 1122.333, 'total_liter_m3': 1234.567, - 'total_power_export_kwh': 13086.777, - 'total_power_export_t1_kwh': 4321.333, - 'total_power_export_t2_kwh': 8765.444, - 'total_power_export_t3_kwh': None, - 'total_power_export_t4_kwh': None, - 'total_power_import_kwh': 13779.338, - 'total_power_import_t1_kwh': 10830.511, - 'total_power_import_t2_kwh': 2948.827, - 'total_power_import_t3_kwh': None, - 'total_power_import_t4_kwh': None, + 'total_energy_export_kwh': 13086.777, + 'total_energy_export_t1_kwh': 4321.333, + 'total_energy_export_t2_kwh': 8765.444, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 13779.338, + 'total_energy_import_t1_kwh': 10830.511, + 'total_energy_import_t2_kwh': 2948.827, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, 'unique_meter_id': '**REDACTED**', 'voltage_sag_l1_count': 1, 'voltage_sag_l2_count': 2, @@ -96,18 +96,18 @@ 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', 'monthly_power_peak_w': 1111.0, 'smr_version': 50, + 'total_energy_export_kwh': 13086.777, + 'total_energy_export_t1_kwh': 4321.333, + 'total_energy_export_t2_kwh': 8765.444, + 'total_energy_export_t3_kwh': 8765.444, + 'total_energy_export_t4_kwh': 8765.444, + 'total_energy_import_kwh': 13779.338, + 'total_energy_import_t1_kwh': 10830.511, + 'total_energy_import_t2_kwh': 2948.827, + 'total_energy_import_t3_kwh': 2948.827, + 'total_energy_import_t4_kwh': 2948.827, 'total_gas_m3': 1122.333, 'total_liter_m3': 1234.567, - 'total_power_export_kwh': 13086.777, - 'total_power_export_t1_kwh': 4321.333, - 'total_power_export_t2_kwh': 8765.444, - 'total_power_export_t3_kwh': 8765.444, - 'total_power_export_t4_kwh': 8765.444, - 'total_power_import_kwh': 13779.338, - 'total_power_import_t1_kwh': 10830.511, - 'total_power_import_t2_kwh': 2948.827, - 'total_power_import_t3_kwh': 2948.827, - 'total_power_import_t4_kwh': 2948.827, 'unique_meter_id': '**REDACTED**', 'voltage_sag_l1_count': 1, 'voltage_sag_l2_count': 2, @@ -165,18 +165,18 @@ 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', 'monthly_power_peak_w': 1111.0, 'smr_version': 50, + 'total_energy_export_kwh': 13086.777, + 'total_energy_export_t1_kwh': 4321.333, + 'total_energy_export_t2_kwh': 8765.444, + 'total_energy_export_t3_kwh': 8765.444, + 'total_energy_export_t4_kwh': 8765.444, + 'total_energy_import_kwh': 13779.338, + 'total_energy_import_t1_kwh': 10830.511, + 'total_energy_import_t2_kwh': 2948.827, + 'total_energy_import_t3_kwh': 2948.827, + 'total_energy_import_t4_kwh': 2948.827, 'total_gas_m3': 1122.333, 'total_liter_m3': 1234.567, - 'total_power_export_kwh': 13086.777, - 'total_power_export_t1_kwh': 4321.333, - 'total_power_export_t2_kwh': 8765.444, - 'total_power_export_t3_kwh': 8765.444, - 'total_power_export_t4_kwh': 8765.444, - 'total_power_import_kwh': 13779.338, - 'total_power_import_t1_kwh': 10830.511, - 'total_power_import_t2_kwh': 2948.827, - 'total_power_import_t3_kwh': 2948.827, - 'total_power_import_t4_kwh': 2948.827, 'unique_meter_id': '**REDACTED**', 'voltage_sag_l1_count': 1, 'voltage_sag_l2_count': 2, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 76df8ed9e29..2890e81d603 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -2331,6 +2331,806 @@ 'via_device_id': None, }) # --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '13086.777', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '4321.333', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '13779.338', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '10830.511', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- # name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 4c0eab22293..de1a2e545de 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -33,16 +33,16 @@ pytestmark = [ "sensor.device_wi_fi_ssid", "sensor.device_active_tariff", "sensor.device_wi_fi_strength", - "sensor.device_total_power_import", - "sensor.device_total_power_import_tariff_1", - "sensor.device_total_power_import_tariff_2", - "sensor.device_total_power_import_tariff_3", - "sensor.device_total_power_import_tariff_4", - "sensor.device_total_power_export", - "sensor.device_total_power_export_tariff_1", - "sensor.device_total_power_export_tariff_2", - "sensor.device_total_power_export_tariff_3", - "sensor.device_total_power_export_tariff_4", + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", "sensor.device_active_power", "sensor.device_active_power_phase_1", "sensor.device_active_power_phase_2", @@ -120,11 +120,11 @@ async def test_disabled_by_default_sensors( @pytest.mark.parametrize( "entity_id", [ - "sensor.device_total_power_export", - "sensor.device_total_power_export_tariff_1", - "sensor.device_total_power_export_tariff_2", - "sensor.device_total_power_export_tariff_3", - "sensor.device_total_power_export_tariff_4", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", ], ) async def test_disabled_by_default_sensors_when_unused( @@ -147,7 +147,7 @@ async def test_sensors_unreachable( exception: Exception, ) -> None: """Test sensor handles API unreachable.""" - assert (state := hass.states.get("sensor.device_total_power_import_tariff_1")) + assert (state := hass.states.get("sensor.device_total_energy_import_tariff_1")) assert state.state == "10830.511" mock_homewizardenergy.data.side_effect = exception From 7319abcab0929ffa6fa0bb847ec9ef9ce5083ea3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Oct 2023 19:40:27 +0100 Subject: [PATCH 100/982] Show a warning when no Withings data found (#103066) --- homeassistant/components/withings/sensor.py | 6 ++++++ tests/components/withings/test_sensor.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 1bef72c48ec..707059a2930 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -40,6 +40,7 @@ from homeassistant.util import dt as dt_util from . import WithingsData from .const import ( DOMAIN, + LOGGER, SCORE_POINTS, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, @@ -787,6 +788,11 @@ async def async_setup_entry( _async_add_workout_entities ) + if not entities: + LOGGER.warning( + "No data found for Withings entry %s, sensors will be added when new data is available" + ) + async_add_entities(entities) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 0bf6b323146..5d42ace495b 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch +from aiowithings import Goals from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -341,3 +342,20 @@ async def test_workout_sensors_created_when_receive_workout_data( await hass.async_block_till_done() assert hass.states.get("sensor.henk_last_workout_type") + + +async def test_warning_if_no_entities_created( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we log a warning if no entities are created at startup.""" + withings.get_workouts_in_period.return_value = [] + withings.get_goals.return_value = Goals(None, None, None) + withings.get_measurement_in_period.return_value = [] + withings.get_sleep_summary_since.return_value = [] + withings.get_activities_since.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert "No data found for Withings entry" in caplog.text From d97a030872abfdbfc544a0d5fb5315699a52f38a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 30 Oct 2023 21:43:24 +0100 Subject: [PATCH 101/982] Refactor todo services and their schema (#103079) --- homeassistant/components/todo/__init__.py | 86 +++++------ homeassistant/components/todo/services.yaml | 27 ++-- homeassistant/components/todo/strings.json | 46 +++--- tests/components/todo/test_init.py | 152 +++++++++++--------- 4 files changed, 149 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 12eac858f75..968256ce3d9 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -43,14 +43,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_handle_todo_item_move) component.async_register_entity_service( - "create_item", + "add_item", { - vol.Required("summary"): vol.All(cv.string, vol.Length(min=1)), - vol.Optional("status", default=TodoItemStatus.NEEDS_ACTION): vol.In( - {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} - ), + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), }, - _async_create_todo_item, + _async_add_todo_item, required_features=[TodoListEntityFeature.CREATE_TODO_ITEM], ) component.async_register_entity_service( @@ -58,30 +55,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.All( cv.make_entity_service_schema( { - vol.Optional("uid"): cv.string, - vol.Optional("summary"): vol.All(cv.string, vol.Length(min=1)), + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), + vol.Optional("rename"): vol.All(cv.string, vol.Length(min=1)), vol.Optional("status"): vol.In( {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} ), } ), - cv.has_at_least_one_key("uid", "summary"), + cv.has_at_least_one_key("rename", "status"), ), _async_update_todo_item, required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], ) component.async_register_entity_service( - "delete_item", - vol.All( - cv.make_entity_service_schema( - { - vol.Optional("uid"): vol.All(cv.ensure_list, [cv.string]), - vol.Optional("summary"): vol.All(cv.ensure_list, [cv.string]), - } - ), - cv.has_at_least_one_key("uid", "summary"), + "remove_item", + cv.make_entity_service_schema( + { + vol.Required("item"): vol.All(cv.ensure_list, [cv.string]), + } ), - _async_delete_todo_items, + _async_remove_todo_items, required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], ) @@ -114,13 +107,6 @@ class TodoItem: status: TodoItemStatus | None = None """A status or confirmation of the To-do item.""" - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> "TodoItem": - """Create a To-do Item from a dictionary parsed by schema validators.""" - return cls( - summary=obj.get("summary"), status=obj.get("status"), uid=obj.get("uid") - ) - class TodoListEntity(Entity): """An entity that represents a To-do list.""" @@ -232,39 +218,43 @@ async def websocket_handle_todo_item_move( connection.send_result(msg["id"]) -def _find_by_summary(summary: str, items: list[TodoItem] | None) -> TodoItem | None: - """Find a To-do List item by summary name.""" +def _find_by_uid_or_summary( + value: str, items: list[TodoItem] | None +) -> TodoItem | None: + """Find a To-do List item by uid or summary name.""" for item in items or (): - if item.summary == summary: + if value in (item.uid, item.summary): return item return None -async def _async_create_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: +async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Add an item to the To-do list.""" - await entity.async_create_todo_item(item=TodoItem.from_dict(call.data)) + await entity.async_create_todo_item( + item=TodoItem(summary=call.data["item"], status=TodoItemStatus.NEEDS_ACTION) + ) async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Update an item in the To-do list.""" - item = TodoItem.from_dict(call.data) - if not item.uid: - found = _find_by_summary(call.data["summary"], entity.todo_items) - if not found: - raise ValueError(f"Unable to find To-do item with summary '{item.summary}'") - item.uid = found.uid + item = call.data["item"] + found = _find_by_uid_or_summary(item, entity.todo_items) + if not found: + raise ValueError(f"Unable to find To-do item '{item}'") - await entity.async_update_todo_item(item=item) + update_item = TodoItem( + uid=found.uid, summary=call.data.get("rename"), status=call.data.get("status") + ) + + await entity.async_update_todo_item(item=update_item) -async def _async_delete_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: - """Delete an item in the To-do list.""" - uids = call.data.get("uid", []) - if not uids: - summaries = call.data.get("summary", []) - for summary in summaries: - item = _find_by_summary(summary, entity.todo_items) - if not item: - raise ValueError(f"Unable to find To-do item with summary '{summary}") - uids.append(item.uid) +async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: + """Remove an item in the To-do list.""" + uids = [] + for item in call.data.get("item", []): + found = _find_by_uid_or_summary(item, entity.todo_items) + if not found or not found.uid: + raise ValueError(f"Unable to find To-do item '{item}") + uids.append(found.uid) await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index c31a7e88808..4d6237760ca 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -1,23 +1,15 @@ -create_item: +add_item: target: entity: domain: todo supported_features: - todo.TodoListEntityFeature.CREATE_TODO_ITEM fields: - summary: + item: required: true example: "Submit income tax return" selector: text: - status: - example: "needs_action" - selector: - select: - translation_key: status - options: - - needs_action - - completed update_item: target: entity: @@ -25,11 +17,13 @@ update_item: supported_features: - todo.TodoListEntityFeature.UPDATE_TODO_ITEM fields: - uid: + item: + required: true + example: "Submit income tax return" selector: text: - summary: - example: "Submit income tax return" + rename: + example: "Something else" selector: text: status: @@ -40,16 +34,13 @@ update_item: options: - needs_action - completed -delete_item: +remove_item: target: entity: domain: todo supported_features: - todo.TodoListEntityFeature.DELETE_TODO_ITEM fields: - uid: - selector: - object: - summary: + item: selector: object: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 623c46375f0..6ba8aaba1a5 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -6,49 +6,41 @@ } }, "services": { - "create_item": { - "name": "Create to-do list item", + "add_item": { + "name": "Add to-do list item", "description": "Add a new to-do list item.", "fields": { - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." - }, - "status": { - "name": "Status", - "description": "A status or confirmation of the to-do item." + "item": { + "name": "Item name", + "description": "The name that represents the to-do item." } } }, "update_item": { "name": "Update to-do list item", - "description": "Update an existing to-do list item based on either its unique ID or summary.", + "description": "Update an existing to-do list item based on its name.", "fields": { - "uid": { - "name": "To-do item unique ID", - "description": "Unique identifier for the to-do list item." + "item": { + "name": "Item name", + "description": "The name for the to-do list item." }, - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." + "rename": { + "name": "Rename item", + "description": "The new name of the to-do item" }, "status": { - "name": "Status", + "name": "Set status", "description": "A status or confirmation of the to-do item." } } }, - "delete_item": { - "name": "Delete a to-do list item", - "description": "Delete an existing to-do list item either by its unique ID or summary.", + "remove_item": { + "name": "Remove a to-do list item", + "description": "Remove an existing to-do list item by its name.", "fields": { - "uid": { - "name": "To-do item unique IDs", - "description": "Unique identifiers for the to-do list items." - }, - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." + "item": { + "name": "Item name", + "description": "The name for the to-do list items." } } } diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index f4d671ad352..3e84049efa8 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -197,28 +197,18 @@ async def test_unsupported_websocket( assert resp.get("error", {}).get("code") == "not_found" -@pytest.mark.parametrize( - ("item_data", "expected_status"), - [ - ({}, TodoItemStatus.NEEDS_ACTION), - ({"status": "needs_action"}, TodoItemStatus.NEEDS_ACTION), - ({"status": "completed"}, TodoItemStatus.COMPLETED), - ], -) -async def test_create_item_service( +async def test_add_item_service( hass: HomeAssistant, - item_data: dict[str, Any], - expected_status: TodoItemStatus, test_entity: TodoListEntity, ) -> None: - """Test creating an item in a To-do list.""" + """Test adding an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "create_item", - {"summary": "New item", **item_data}, + "add_item", + {"item": "New item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -229,14 +219,14 @@ async def test_create_item_service( assert item assert item.uid is None assert item.summary == "New item" - assert item.status == expected_status + assert item.status == TodoItemStatus.NEEDS_ACTION -async def test_create_item_service_raises( +async def test_add_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test creating an item in a To-do list that raises an error.""" + """Test adding an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) @@ -244,8 +234,8 @@ async def test_create_item_service_raises( with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, - "create_item", - {"summary": "New item", "status": "needs_action"}, + "add_item", + {"item": "New item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -255,27 +245,23 @@ async def test_create_item_service_raises( ("item_data", "expected_error"), [ ({}, "required key not provided"), - ({"status": "needs_action"}, "required key not provided"), - ( - {"summary": "", "status": "needs_action"}, - "length of value must be at least 1", - ), + ({"item": ""}, "length of value must be at least 1"), ], ) -async def test_create_item_service_invalid_input( +async def test_add_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, item_data: dict[str, Any], expected_error: str, ) -> None: - """Test invalid input to the create item service.""" + """Test invalid input to the add item service.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(vol.Invalid, match=expected_error): await hass.services.async_call( DOMAIN, - "create_item", + "add_item", item_data, target={"entity_id": "todo.entity1"}, blocking=True, @@ -293,7 +279,7 @@ async def test_update_todo_item_service_by_id( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -302,7 +288,7 @@ async def test_update_todo_item_service_by_id( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary == "Updated item" assert item.status == TodoItemStatus.COMPLETED @@ -318,7 +304,7 @@ async def test_update_todo_item_service_by_id_status_only( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "status": "completed"}, + {"item": "1", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -327,12 +313,12 @@ async def test_update_todo_item_service_by_id_status_only( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary is None assert item.status == TodoItemStatus.COMPLETED -async def test_update_todo_item_service_by_id_summary_only( +async def test_update_todo_item_service_by_id_rename( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: @@ -343,7 +329,7 @@ async def test_update_todo_item_service_by_id_summary_only( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item"}, + {"item": "1", "rename": "Updated item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -352,7 +338,7 @@ async def test_update_todo_item_service_by_id_summary_only( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary == "Updated item" assert item.status is None @@ -368,7 +354,7 @@ async def test_update_todo_item_service_raises( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -378,7 +364,7 @@ async def test_update_todo_item_service_raises( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -395,7 +381,7 @@ async def test_update_todo_item_service_by_summary( await hass.services.async_call( DOMAIN, "update_item", - {"summary": "Item #1", "status": "completed"}, + {"item": "Item #1", "rename": "Something else", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -405,10 +391,35 @@ async def test_update_todo_item_service_by_summary( item = args.kwargs.get("item") assert item assert item.uid == "1" - assert item.summary == "Item #1" + assert item.summary == "Something else" assert item.status == TodoItemStatus.COMPLETED +async def test_update_todo_item_service_by_summary_only_status( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list by summary.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "Item #1", "rename": "Something else"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary == "Something else" + assert item.status is None + + async def test_update_todo_item_service_by_summary_not_found( hass: HomeAssistant, test_entity: TodoListEntity, @@ -421,7 +432,7 @@ async def test_update_todo_item_service_by_summary_not_found( await hass.services.async_call( DOMAIN, "update_item", - {"summary": "Item #7", "status": "completed"}, + {"item": "Item #7", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -430,10 +441,11 @@ async def test_update_todo_item_service_by_summary_not_found( @pytest.mark.parametrize( ("item_data", "expected_error"), [ - ({}, "must contain at least one of"), - ({"status": "needs_action"}, "must contain at least one of"), + ({}, r"required key not provided @ data\['item'\]"), + ({"status": "needs_action"}, r"required key not provided @ data\['item'\]"), + ({"item": "Item #1"}, "must contain at least one of"), ( - {"summary": "", "status": "needs_action"}, + {"item": "", "status": "needs_action"}, "length of value must be at least 1", ), ], @@ -458,32 +470,32 @@ async def test_update_item_service_invalid_input( ) -async def test_delete_todo_item_service_by_id( +async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list.""" + """Test removing an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "delete_item", - {"uid": ["item-1", "item-2"]}, + "remove_item", + {"item": ["1", "2"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_delete_todo_items.call_args assert args - assert args.kwargs.get("uids") == ["item-1", "item-2"] + assert args.kwargs.get("uids") == ["1", "2"] -async def test_delete_todo_item_service_raises( +async def test_remove_todo_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list that raises an error.""" + """Test removing an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) @@ -491,43 +503,45 @@ async def test_delete_todo_item_service_raises( with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, - "delete_item", - {"uid": ["item-1", "item-2"]}, + "remove_item", + {"item": ["1", "2"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) -async def test_delete_todo_item_service_invalid_input( +async def test_remove_todo_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test invalid input to the delete item service.""" + """Test invalid input to the remove item service.""" await create_mock_platform(hass, [test_entity]) - with pytest.raises(vol.Invalid, match="must contain at least one of"): + with pytest.raises( + vol.Invalid, match=r"required key not provided @ data\['item'\]" + ): await hass.services.async_call( DOMAIN, - "delete_item", + "remove_item", {}, target={"entity_id": "todo.entity1"}, blocking=True, ) -async def test_delete_todo_item_service_by_summary( +async def test_remove_todo_item_service_by_summary( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list by summary.""" + """Test removing an item in a To-do list by summary.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "delete_item", - {"summary": ["Item #1"]}, + "remove_item", + {"item": ["Item #1"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -537,19 +551,19 @@ async def test_delete_todo_item_service_by_summary( assert args.kwargs.get("uids") == ["1"] -async def test_delete_todo_item_service_by_summary_not_found( +async def test_remove_todo_item_service_by_summary_not_found( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list by summary which is not found.""" + """Test removing an item in a To-do list by summary which is not found.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(ValueError, match="Unable to find"): await hass.services.async_call( DOMAIN, - "delete_item", - {"summary": ["Item #7"]}, + "remove_item", + {"item": ["Item #7"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -656,22 +670,22 @@ async def test_move_todo_item_service_invalid_input( ("service_name", "payload"), [ ( - "create_item", + "add_item", { - "summary": "New item", + "item": "New item", }, ), ( - "delete_item", + "remove_item", { - "uid": ["1"], + "item": ["1"], }, ), ( "update_item", { - "uid": "1", - "summary": "Updated item", + "item": "1", + "rename": "Updated item", }, ), ], From 84b71c9ddb3bc5b7536205a3376fd0909a09cfcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Mandari=C4=87?= <2945713+MislavMandaric@users.noreply.github.com> Date: Mon, 30 Oct 2023 22:10:47 +0100 Subject: [PATCH 102/982] Allow setting hvac mode through set_temperature climate method in Gree integration (#101196) * Allow setting hvac mode through set_temperature climate method * Suggested code simplification when reading hvac mode Co-authored-by: G Johansson * Remove unnecessary temperature unit handling from set temperature with hvac mode tests --------- Co-authored-by: G Johansson --- homeassistant/components/gree/climate.py | 4 +++ tests/components/gree/test_climate.py | 44 +++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 17d915feadb..b14b9cfaba4 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -17,6 +17,7 @@ from greeclimate.device import ( ) from homeassistant.components.climate import ( + ATTR_HVAC_MODE, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -158,6 +159,9 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE if ATTR_TEMPERATURE not in kwargs: raise ValueError(f"Missing parameter {ATTR_TEMPERATURE}") + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + await self.async_set_hvac_mode(hvac_mode) + temperature = kwargs[ATTR_TEMPERATURE] _LOGGER.debug( "Setting temperature to %d for %s", diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index fe64b0ee7ef..82ad75b5d28 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -34,7 +34,11 @@ from homeassistant.components.climate import ( SWING_VERTICAL, HVACMode, ) -from homeassistant.components.gree.climate import FAN_MODES_REVERSE, HVAC_MODES_REVERSE +from homeassistant.components.gree.climate import ( + FAN_MODES_REVERSE, + HVAC_MODES, + HVAC_MODES_REVERSE, +) from homeassistant.components.gree.const import FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW from homeassistant.const import ( ATTR_ENTITY_ID, @@ -384,6 +388,9 @@ async def test_send_target_temperature( """Test for sending target temperature command to the device.""" hass.config.units.temperature_unit = units + device().power = True + device().mode = HVAC_MODES_REVERSE.get(HVACMode.AUTO) + fake_device = device() if units == UnitOfTemperature.FAHRENHEIT: fake_device.temperature_units = 1 @@ -407,12 +414,47 @@ async def test_send_target_temperature( state.attributes.get(ATTR_CURRENT_TEMPERATURE) == fake_device.current_temperature ) + assert state.state == HVAC_MODES.get(fake_device.mode) # Reset config temperature_unit back to CELSIUS, required for # additional tests outside this component. hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS +@pytest.mark.parametrize( + ("temperature", "hvac_mode"), + [ + (26, HVACMode.OFF), + (26, HVACMode.HEAT), + (26, HVACMode.COOL), + (26, HVACMode.AUTO), + (26, HVACMode.DRY), + (26, HVACMode.FAN_ONLY), + ], +) +async def test_send_target_temperature_with_hvac_mode( + hass: HomeAssistant, discovery, device, temperature, hvac_mode +) -> None: + """Test for sending target temperature command to the device alongside hvac mode.""" + await async_setup_gree(hass) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: temperature, + ATTR_HVAC_MODE: hvac_mode, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_TEMPERATURE) == temperature + assert state.state == hvac_mode + + @pytest.mark.parametrize( ("units", "temperature"), [(UnitOfTemperature.CELSIUS, 25), (UnitOfTemperature.FAHRENHEIT, 74)], From 6e62cf5efbefedcf051b23d1c393734f2f02d700 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:03:34 +0100 Subject: [PATCH 103/982] Fix google_tasks todo tests (#103098) --- tests/components/google_tasks/test_todo.py | 28 +++++++++++++--------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 5dc7f10fea0..e19ac1272cd 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -30,6 +30,12 @@ LIST_TASKS_RESPONSE = { "items": [], } +LIST_TASKS_RESPONSE_WATER = { + "items": [ + {"id": "some-task-id", "title": "Water", "status": "needsAction"}, + ], +} + @pytest.fixture def platforms() -> list[str]: @@ -198,8 +204,8 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "Soda"}, + "add_item", + {"item": "Soda"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -215,7 +221,7 @@ async def test_create_todo_list_item( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -234,12 +240,12 @@ async def test_update_todo_list_item( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "summary": "Soda", "status": "completed"}, + {"item": "some-task-id", "rename": "Soda", "status": "completed"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -255,7 +261,7 @@ async def test_update_todo_list_item( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -274,12 +280,12 @@ async def test_partial_update_title( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "summary": "Soda"}, + {"item": "some-task-id", "rename": "Soda"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -295,7 +301,7 @@ async def test_partial_update_title( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -314,12 +320,12 @@ async def test_partial_update_status( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "status": "needs_action"}, + {"item": "some-task-id", "status": "needs_action"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) From df814af076a31852bc90914073610a7ab646a953 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:03:54 +0100 Subject: [PATCH 104/982] Fix shopping_list todo tests (#103100) --- tests/components/shopping_list/test_todo.py | 83 ++++++++------------- 1 file changed, 31 insertions(+), 52 deletions(-) diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index ab28c6cbe6d..681ccea60ac 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator @@ -115,18 +114,18 @@ async def test_get_items( assert state.state == "1" -async def test_create_item( +async def test_add_item( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test creating shopping_list item and listing it.""" + """Test adding shopping_list item and listing it.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -142,38 +141,18 @@ async def test_create_item( assert state assert state.state == "1" - # Add a completed item - await hass.services.async_call( - TODO_DOMAIN, - "create_item", - {"summary": "paper", "status": "completed"}, - target={"entity_id": TEST_ENTITY}, - blocking=True, - ) - items = await ws_get_items() - assert len(items) == 2 - assert items[0]["summary"] == "soda" - assert items[0]["status"] == "needs_action" - assert items[1]["summary"] == "paper" - assert items[1]["status"] == "completed" - - state = hass.states.get(TEST_ENTITY) - assert state - assert state.state == "1" - - -async def test_delete_item( +async def test_remove_item( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda", "status": "needs_action"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -189,9 +168,9 @@ async def test_delete_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", + "remove_item", { - "uid": [items[0]["uid"]], + "item": [items[0]["uid"]], }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -205,20 +184,20 @@ async def test_delete_item( assert state.state == "0" -async def test_bulk_delete( +async def test_bulk_remove( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" for _i in range(0, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -234,9 +213,9 @@ async def test_bulk_delete( await hass.services.async_call( TODO_DOMAIN, - "delete_item", + "remove_item", { - "uid": uids, + "item": uids, }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -261,9 +240,9 @@ async def test_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -285,7 +264,7 @@ async def test_update_item( TODO_DOMAIN, "update_item", { - **item, + "item": "soda", "status": "completed", }, target={"entity_id": TEST_ENTITY}, @@ -315,9 +294,9 @@ async def test_partial_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -339,7 +318,7 @@ async def test_partial_update_item( TODO_DOMAIN, "update_item", { - "uid": item["uid"], + "item": item["uid"], "status": "completed", }, target={"entity_id": TEST_ENTITY}, @@ -362,8 +341,8 @@ async def test_partial_update_item( TODO_DOMAIN, "update_item", { - "uid": item["uid"], - "summary": "other summary", + "item": item["uid"], + "rename": "other summary", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -389,13 +368,13 @@ async def test_update_invalid_item( ) -> None: """Test updating a todo item that does not exist.""" - with pytest.raises(HomeAssistantError, match="was not found"): + with pytest.raises(ValueError, match="Unable to find"): await hass.services.async_call( TODO_DOMAIN, "update_item", { - "uid": "invalid-uid", - "summary": "Example task", + "item": "invalid-uid", + "rename": "Example task", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -443,9 +422,9 @@ async def test_move_item( for i in range(1, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": f"item {i}", + "item": f"item {i}", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -481,8 +460,8 @@ async def test_move_invalid_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) From 64f8967739b4e386ea6dacdb104cf4b2a5d7e7c9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:38:18 +0100 Subject: [PATCH 105/982] Fix todoist todo tests (#103101) --- tests/components/todoist/test_todo.py | 46 +++++++-------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index bbfaf6c493b..a14f362ea5b 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -56,12 +56,12 @@ async def test_todo_item_state( @pytest.mark.parametrize(("tasks"), [[]]) -async def test_create_todo_list_item( +async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, ) -> None: - """Test for creating a To-do Item.""" + """Test for adding a To-do Item.""" state = hass.states.get("todo.name") assert state @@ -75,8 +75,8 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "Soda"}, + "add_item", + {"item": "Soda"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -92,30 +92,6 @@ async def test_create_todo_list_item( assert state.state == "1" -@pytest.mark.parametrize(("tasks"), [[]]) -async def test_create_completed_item_unsupported( - hass: HomeAssistant, - setup_integration: None, - api: AsyncMock, -) -> None: - """Test for creating a To-do Item that is already completed.""" - - state = hass.states.get("todo.name") - assert state - assert state.state == "0" - - api.add_task = AsyncMock() - - with pytest.raises(ValueError, match="Only active tasks"): - await hass.services.async_call( - TODO_DOMAIN, - "create_item", - {"summary": "Soda", "status": "completed"}, - target={"entity_id": "todo.name"}, - blocking=True, - ) - - @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] ) @@ -141,7 +117,7 @@ async def test_update_todo_item_status( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "status": "completed"}, + {"item": "task-id-1", "status": "completed"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -164,7 +140,7 @@ async def test_update_todo_item_status( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "status": "needs_action"}, + {"item": "task-id-1", "status": "needs_action"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -203,7 +179,7 @@ async def test_update_todo_item_summary( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "summary": "Milk"}, + {"item": "task-id-1", "rename": "Milk"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -223,12 +199,12 @@ async def test_update_todo_item_summary( ] ], ) -async def test_delete_todo_item( +async def test_remove_todo_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, ) -> None: - """Test for deleting a To-do Item.""" + """Test for removing a To-do Item.""" state = hass.states.get("todo.name") assert state @@ -240,8 +216,8 @@ async def test_delete_todo_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": ["task-id-1", "task-id-2"]}, + "remove_item", + {"item": ["task-id-1", "task-id-2"]}, target={"entity_id": "todo.name"}, blocking=True, ) From 246ebc99ccfe2a6b3bb558c2d804b433d79035ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:38:58 +0100 Subject: [PATCH 106/982] Fix local_todo todo tests (#103099) --- tests/components/local_todo/test_todo.py | 46 ++++++++++++------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 8a7e38c9773..39e9264d45a 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -79,13 +79,13 @@ async def ws_move_item( return move -async def test_create_item( +async def test_add_item( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test creating a todo item.""" + """Test adding a todo item.""" state = hass.states.get(TEST_ENTITY) assert state @@ -93,8 +93,8 @@ async def test_create_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "replace batteries"}, + "add_item", + {"item": "replace batteries"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -110,16 +110,16 @@ async def test_create_item( assert state.state == "1" -async def test_delete_item( +async def test_remove_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "replace batteries"}, + "add_item", + {"item": "replace batteries"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -136,8 +136,8 @@ async def test_delete_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": [items[0]["uid"]]}, + "remove_item", + {"item": [items[0]["uid"]]}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -150,17 +150,17 @@ async def test_delete_item( assert state.state == "0" -async def test_bulk_delete( +async def test_bulk_remove( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting multiple todo items.""" + """Test removing multiple todo items.""" for i in range(0, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": f"soda #{i}"}, + "add_item", + {"item": f"soda #{i}"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -175,8 +175,8 @@ async def test_bulk_delete( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": uids}, + "remove_item", + {"item": uids}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -199,8 +199,8 @@ async def test_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -220,7 +220,7 @@ async def test_update_item( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": item["uid"], "status": "completed"}, + {"item": item["uid"], "status": "completed"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -276,8 +276,8 @@ async def test_move_item( for i in range(1, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": f"item {i}"}, + "add_item", + {"item": f"item {i}"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -334,8 +334,8 @@ async def test_move_item_previous_unknown( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "item 1"}, + "add_item", + {"item": "item 1"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) From 12afd0ad9495f39d31db5a04e2c4c56a37e5d83f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 31 Oct 2023 06:57:06 +0100 Subject: [PATCH 107/982] Bump pytrafikverket to 0.3.8 (#103080) --- homeassistant/components/trafikverket_camera/manifest.json | 2 +- homeassistant/components/trafikverket_ferry/manifest.json | 2 +- homeassistant/components/trafikverket_train/manifest.json | 2 +- .../components/trafikverket_weatherstation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 7b457063c6c..a679bd27d50 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.7"] + "requirements": ["pytrafikverket==0.3.8"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 7d0171bc8bb..a62c05a9baf 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.7"] + "requirements": ["pytrafikverket==0.3.8"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index b1dd39c5156..8c23cb02258 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.7"] + "requirements": ["pytrafikverket==0.3.8"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index d9b4f20eeb7..d13eda72835 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.7"] + "requirements": ["pytrafikverket==0.3.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index a45a933f8d6..1fca406f33d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2220,7 +2220,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.7 +pytrafikverket==0.3.8 # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1ebc094650..b9c0a0841b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1655,7 +1655,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.7 +pytrafikverket==0.3.8 # homeassistant.components.usb pyudev==0.23.2 From 8e3b5f1be4c4b5a8723cd0549949d7b0e72d4d81 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 07:55:03 +0100 Subject: [PATCH 108/982] Add todo to core files (#103102) --- .core_files.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.core_files.yaml b/.core_files.yaml index b3e854de04b..f5ffdee9142 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -45,6 +45,7 @@ base_platforms: &base_platforms - homeassistant/components/switch/** - homeassistant/components/text/** - homeassistant/components/time/** + - homeassistant/components/todo/** - homeassistant/components/tts/** - homeassistant/components/update/** - homeassistant/components/vacuum/** From 06bbea2e0f50742eb4eae358b335c4268f5e6321 Mon Sep 17 00:00:00 2001 From: Paul Manzotti Date: Tue, 31 Oct 2023 07:09:03 +0000 Subject: [PATCH 109/982] Update geniushub-client to v0.7.1 (#103071) --- homeassistant/components/geniushub/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 4029023bb07..28079293821 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geniushub", "iot_class": "local_polling", "loggers": ["geniushubclient"], - "requirements": ["geniushub-client==0.7.0"] + "requirements": ["geniushub-client==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1fca406f33d..ff8a594352c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -857,7 +857,7 @@ gassist-text==0.0.10 gcal-sync==5.0.0 # homeassistant.components.geniushub -geniushub-client==0.7.0 +geniushub-client==0.7.1 # homeassistant.components.geocaching geocachingapi==0.2.1 From 85d49a29202897b8ecad743667b30a443764315d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 31 Oct 2023 08:31:53 +0100 Subject: [PATCH 110/982] Fix Met Device Info (#103082) --- homeassistant/components/met/__init__.py | 12 +++++++++++ homeassistant/components/met/weather.py | 14 ++++++------- tests/components/met/test_init.py | 26 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 16bfc93f715..53764252043 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -68,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + await cleanup_old_device(hass) + return True @@ -88,6 +91,15 @@ async def async_update_entry(hass: HomeAssistant, config_entry: ConfigEntry): await hass.config_entries.async_reload(config_entry.entry_id) +async def cleanup_old_device(hass: HomeAssistant) -> None: + """Cleanup device without proper device identifier.""" + device_reg = dr.async_get(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN,)}) # type: ignore[arg-type] + if device: + _LOGGER.debug("Removing improper device %s", device.name) + device_reg.async_remove_device(device.id) + + class CannotConnect(HomeAssistantError): """Unable to connect to the web site.""" diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index def06634f42..8a5c405c1c1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -60,7 +60,7 @@ async def async_setup_entry( if TYPE_CHECKING: assert isinstance(name, str) - entities = [MetWeather(coordinator, config_entry.data, False, name, is_metric)] + entities = [MetWeather(coordinator, config_entry, False, name, is_metric)] # Add hourly entity to legacy config entries if entity_registry.async_get_entity_id( @@ -69,9 +69,7 @@ async def async_setup_entry( _calculate_unique_id(config_entry.data, True), ): name = f"{name} hourly" - entities.append( - MetWeather(coordinator, config_entry.data, True, name, is_metric) - ) + entities.append(MetWeather(coordinator, config_entry, True, name, is_metric)) async_add_entities(entities) @@ -114,22 +112,22 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): def __init__( self, coordinator: MetDataUpdateCoordinator, - config: MappingProxyType[str, Any], + config_entry: ConfigEntry, hourly: bool, name: str, is_metric: bool, ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) - self._attr_unique_id = _calculate_unique_id(config, hourly) - self._config = config + self._attr_unique_id = _calculate_unique_id(config_entry.data, hourly) + self._config = config_entry.data self._is_metric = is_metric self._hourly = hourly self._attr_entity_registry_enabled_default = not hourly self._attr_device_info = DeviceInfo( name="Forecast", entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, # type: ignore[arg-type] + identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer="Met.no", model="Forecast", configuration_url="https://www.met.no/en", diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py index d9085f8251f..652763947df 100644 --- a/tests/components/met/test_init.py +++ b/tests/components/met/test_init.py @@ -9,6 +9,7 @@ from homeassistant.components.met.const import ( from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import init_integration @@ -48,3 +49,28 @@ async def test_fail_default_home_entry( "Skip setting up met.no integration; No Home location has been set" in caplog.text ) + + +async def test_removing_incorrect_devices( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_weather +) -> None: + """Test we remove incorrect devices.""" + entry = await init_integration(hass) + + device_reg = dr.async_get(hass) + device_reg.async_get_or_create( + config_entry_id=entry.entry_id, + name="Forecast_legacy", + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, + manufacturer="Met.no", + model="Forecast", + configuration_url="https://www.met.no/en", + ) + + assert await hass.config_entries.async_reload(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert not device_reg.async_get_device(identifiers={(DOMAIN,)}) + assert device_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + assert "Removing improper device Forecast_legacy" in caplog.text From 61a1245a84bdd703ca1f4ac5ab2f76a374d381ac Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 31 Oct 2023 11:49:03 +0400 Subject: [PATCH 111/982] Code cleanup for transmission integration (#103078) --- homeassistant/components/transmission/__init__.py | 12 +----------- homeassistant/components/transmission/config_flow.py | 9 +++------ homeassistant/components/transmission/coordinator.py | 8 ++++---- homeassistant/components/transmission/strings.json | 2 -- 4 files changed, 8 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 7d019935e6c..df78c5d96aa 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -1,7 +1,6 @@ """Support for the Transmission BitTorrent client API.""" from __future__ import annotations -from datetime import timedelta from functools import partial import logging import re @@ -22,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_USERNAME, Platform, ) @@ -69,7 +67,7 @@ MIGRATION_NAME_TO_KEY = { SERVICE_BASE_SCHEMA = vol.Schema( { - vol.Exclusive(CONF_ENTRY_ID, "identifier"): selector.ConfigEntrySelector(), + vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(), } ) @@ -135,7 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.add_update_listener(async_options_updated) async def add_torrent(service: ServiceCall) -> None: """Add new torrent to download.""" @@ -244,10 +241,3 @@ async def get_api( except TransmissionError as error: _LOGGER.error(error) raise UnknownError from error - - -async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Triggered by config entry options updates.""" - coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - coordinator.update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL]) - await coordinator.async_request_refresh() diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index d16981add87..a987233fef0 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -55,12 +55,9 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - for entry in self._async_current_entries(): - if ( - entry.data[CONF_HOST] == user_input[CONF_HOST] - and entry.data[CONF_PORT] == user_input[CONF_PORT] - ): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) try: await get_api(self.hass, user_input) diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 9df509b9783..91597d0e43d 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -71,13 +71,13 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): data = self.api.session_stats() self.torrents = self.api.get_torrents() self._session = self.api.get_session() - - self.check_completed_torrent() - self.check_started_torrent() - self.check_removed_torrent() except transmission_rpc.TransmissionError as err: raise UpdateFailed("Unable to connect to Transmission client") from err + self.check_completed_torrent() + self.check_started_torrent() + self.check_removed_torrent() + return data def init_torrent_list(self) -> None: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 81d94b9aac4..77ffd6a8b2a 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -30,9 +30,7 @@ "options": { "step": { "init": { - "title": "Configure options for Transmission", "data": { - "scan_interval": "Update frequency", "limit": "Limit", "order": "Order" } From b63c33d320c6f2c50c7e66e74a0488d941d9efd2 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 31 Oct 2023 08:53:56 +0100 Subject: [PATCH 112/982] Bumb python-homewizard-energy to 3.1.0 (#103011) Co-authored-by: Franck Nijhof --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 5fce3f2ea2d..b987fd6f208 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==3.0.0"], + "requirements": ["python-homewizard-energy==3.1.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ff8a594352c..95f26670eaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2126,7 +2126,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==3.0.0 +python-homewizard-energy==3.1.0 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9c0a0841b1..b0adf0943c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1588,7 +1588,7 @@ python-ecobee-api==0.2.17 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==3.0.0 +python-homewizard-energy==3.1.0 # homeassistant.components.izone python-izone==1.2.9 From a851907f787e7215ed3971a1f670ad843490b6e6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 31 Oct 2023 09:10:43 +0100 Subject: [PATCH 113/982] Add serial to Sensibo (#103089) --- homeassistant/components/sensibo/entity.py | 1 + homeassistant/components/sensibo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 4eff1a011a5..9f20c051576 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -86,6 +86,7 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): sw_version=self.device_data.fw_ver, hw_version=self.device_data.fw_type, suggested_area=self.device_data.name, + serial_number=self.device_data.serial, ) diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 016b3a1e9d9..5a195a8a4cc 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -15,5 +15,5 @@ "iot_class": "cloud_polling", "loggers": ["pysensibo"], "quality_scale": "platinum", - "requirements": ["pysensibo==1.0.35"] + "requirements": ["pysensibo==1.0.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index 95f26670eaa..b588bf864f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2007,7 +2007,7 @@ pysaj==0.0.16 pyschlage==2023.10.0 # homeassistant.components.sensibo -pysensibo==1.0.35 +pysensibo==1.0.36 # homeassistant.components.zha pyserial-asyncio-fast==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0adf0943c0..11fb3d2b178 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1514,7 +1514,7 @@ pysabnzbd==1.1.1 pyschlage==2023.10.0 # homeassistant.components.sensibo -pysensibo==1.0.35 +pysensibo==1.0.36 # homeassistant.components.zha pyserial-asyncio-fast==0.11 From 0304ac5a1bef5dd3567837d05745d0d9ac9f463a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 10:05:03 +0100 Subject: [PATCH 114/982] Fix restore state for light when saved attribute is None (#103096) --- .../components/light/reproduce_state.py | 18 +++---- .../components/light/test_reproduce_state.py | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 15141b6d428..f055f02ebda 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -149,31 +149,29 @@ async def _async_reproduce_state( service = SERVICE_TURN_ON for attr in ATTR_GROUP: # All attributes that are not colors - if attr in state.attributes: - service_data[attr] = state.attributes[attr] + if (attr_state := state.attributes.get(attr)) is not None: + service_data[attr] = attr_state if ( state.attributes.get(ATTR_COLOR_MODE, ColorMode.UNKNOWN) != ColorMode.UNKNOWN ): color_mode = state.attributes[ATTR_COLOR_MODE] - if color_mode_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): - if color_mode_attr.state_attr not in state.attributes: + if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): + if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None: _LOGGER.warning( "Color mode %s specified but attribute %s missing for: %s", color_mode, - color_mode_attr.state_attr, + cm_attr.state_attr, state.entity_id, ) return - service_data[color_mode_attr.parameter] = state.attributes[ - color_mode_attr.state_attr - ] + service_data[cm_attr.parameter] = cm_attr_state else: # Fall back to Choosing the first color that is specified for color_attr in COLOR_GROUP: - if color_attr in state.attributes: - service_data[color_attr] = state.attributes[color_attr] + if (color_attr_state := state.attributes.get(color_attr)) is not None: + service_data[color_attr] = color_attr_state break elif state.state == STATE_OFF: diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index f36b8180560..816bde430e7 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -22,6 +22,20 @@ VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} VALID_RGBWW_COLOR = {"rgbww_color": (255, 63, 111, 10, 20)} VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} +NONE_BRIGHTNESS = {"brightness": None} +NONE_FLASH = {"flash": None} +NONE_EFFECT = {"effect": None} +NONE_TRANSITION = {"transition": None} +NONE_COLOR_NAME = {"color_name": None} +NONE_COLOR_TEMP = {"color_temp": None} +NONE_HS_COLOR = {"hs_color": None} +NONE_KELVIN = {"kelvin": None} +NONE_PROFILE = {"profile": None} +NONE_RGB_COLOR = {"rgb_color": None} +NONE_RGBW_COLOR = {"rgbw_color": None} +NONE_RGBWW_COLOR = {"rgbww_color": None} +NONE_XY_COLOR = {"xy_color": None} + async def test_reproducing_states( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -237,3 +251,39 @@ async def test_deprecation_warning( ) assert len(turn_on_calls) == 1 assert DEPRECATION_WARNING % ["brightness_pct"] in caplog.text + + +@pytest.mark.parametrize( + "saved_state", + ( + NONE_BRIGHTNESS, + NONE_FLASH, + NONE_EFFECT, + NONE_TRANSITION, + NONE_COLOR_NAME, + NONE_COLOR_TEMP, + NONE_HS_COLOR, + NONE_KELVIN, + NONE_PROFILE, + NONE_RGB_COLOR, + NONE_RGBW_COLOR, + NONE_RGBWW_COLOR, + NONE_XY_COLOR, + ), +) +async def test_filter_none(hass: HomeAssistant, saved_state) -> None: + """Test filtering of parameters which are None.""" + hass.states.async_set("light.entity", "off", {}) + + turn_on_calls = async_mock_service(hass, "light", "turn_on") + + await async_reproduce_state(hass, [State("light.entity", "on", saved_state)]) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "light" + assert dict(turn_on_calls[0].data) == {"entity_id": "light.entity"} + + # This should do nothing, the light is already in the desired state + hass.states.async_set("light.entity", "on", {}) + await async_reproduce_state(hass, [State("light.entity", "on", saved_state)]) + assert len(turn_on_calls) == 1 From 1fc1e85b0142af30fcd8e82e52603f178b9d94f2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Oct 2023 10:05:16 +0100 Subject: [PATCH 115/982] Update frontend to 20231030.0 (#103086) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a47ef38264e..b1eaaaf77e1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231027.0"] + "requirements": ["home-assistant-frontend==20231030.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5d68cead747..cd1623c7d0d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b588bf864f9..770ce521085 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11fb3d2b178..cd088c89e5b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 8e2c2e5cc50d8eac59a097c71c5169c282a2232b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 31 Oct 2023 10:06:42 +0100 Subject: [PATCH 116/982] Fix todo.remove_item frontend (#103108) --- homeassistant/components/todo/services.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 4d6237760ca..1bdb8aca779 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -42,5 +42,6 @@ remove_item: - todo.TodoListEntityFeature.DELETE_TODO_ITEM fields: item: + required: true selector: - object: + text: From 8668f4754337034ee72faf5e996f9cb1a6402f60 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 31 Oct 2023 10:29:04 +0100 Subject: [PATCH 117/982] Add strict typing for input_text (#103095) --- .strict-typing | 1 + .../components/input_text/__init__.py | 53 ++++++++++--------- mypy.ini | 10 ++++ 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1faf190a1de..b2f27fafbbc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -180,6 +180,7 @@ homeassistant.components.image_upload.* homeassistant.components.imap.* homeassistant.components.input_button.* homeassistant.components.input_select.* +homeassistant.components.input_text.* homeassistant.components.integration.* homeassistant.components.ipp.* homeassistant.components.iqvia.* diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 81b75458dc1..01bd76d1241 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Self +from typing import Any, Self import voluptuous as vol @@ -61,20 +61,20 @@ STORAGE_FIELDS = { } -def _cv_input_text(cfg): +def _cv_input_text(config: dict[str, Any]) -> dict[str, Any]: """Configure validation helper for input box (voluptuous).""" - minimum = cfg.get(CONF_MIN) - maximum = cfg.get(CONF_MAX) + minimum: int = config[CONF_MIN] + maximum: int = config[CONF_MAX] if minimum > maximum: raise vol.Invalid( f"Max len ({minimum}) is not greater than min len ({maximum})" ) - state = cfg.get(CONF_INITIAL) + state: str | None = config.get(CONF_INITIAL) if state is not None and (len(state) < minimum or len(state) > maximum): raise vol.Invalid( f"Initial value {state} length not in range {minimum}-{maximum}" ) - return cfg + return config CONFIG_SCHEMA = vol.Schema( @@ -86,7 +86,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), - vol.Optional(CONF_INITIAL, ""): cv.string, + vol.Optional(CONF_INITIAL): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_PATTERN): cv.string, @@ -162,16 +162,18 @@ class InputTextStorageCollection(collection.DictStorageCollection): CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_text)) - async def _process_create_data(self, data: dict) -> dict: + async def _process_create_data(self, data: dict[str, Any]) -> vol.Schema: """Validate the config is valid.""" return self.CREATE_UPDATE_SCHEMA(data) @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, Any]) -> str: """Suggest an ID based on the config.""" - return info[CONF_NAME] + return info[CONF_NAME] # type: ignore[no-any-return] - async def _update_data(self, item: dict, update_data: dict) -> dict: + async def _update_data( + self, item: dict[str, Any], update_data: dict[str, Any] + ) -> dict[str, Any]: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) return {CONF_ID: item[CONF_ID]} | update_data @@ -185,6 +187,7 @@ class InputText(collection.CollectionEntity, RestoreEntity): ) _attr_should_poll = False + _current_value: str | None editable: bool def __init__(self, config: ConfigType) -> None: @@ -195,55 +198,55 @@ class InputText(collection.CollectionEntity, RestoreEntity): @classmethod def from_storage(cls, config: ConfigType) -> Self: """Return entity instance initialized from storage.""" - input_text = cls(config) + input_text: Self = cls(config) input_text.editable = True return input_text @classmethod def from_yaml(cls, config: ConfigType) -> Self: """Return entity instance initialized from yaml.""" - input_text = cls(config) + input_text: Self = cls(config) input_text.entity_id = f"{DOMAIN}.{config[CONF_ID]}" input_text.editable = False return input_text @property - def name(self): + def name(self) -> str | None: """Return the name of the text input entity.""" return self._config.get(CONF_NAME) @property - def icon(self): + def icon(self) -> str | None: """Return the icon to be used for this entity.""" return self._config.get(CONF_ICON) @property def _maximum(self) -> int: """Return max len of the text.""" - return self._config[CONF_MAX] + return self._config[CONF_MAX] # type: ignore[no-any-return] @property def _minimum(self) -> int: """Return min len of the text.""" - return self._config[CONF_MIN] + return self._config[CONF_MIN] # type: ignore[no-any-return] @property - def state(self): + def state(self) -> str | None: """Return the state of the component.""" return self._current_value @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._config.get(CONF_UNIT_OF_MEASUREMENT) @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return unique id for the entity.""" - return self._config[CONF_ID] + return self._config[CONF_ID] # type: ignore[no-any-return] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_EDITABLE: self.editable, @@ -253,20 +256,20 @@ class InputText(collection.CollectionEntity, RestoreEntity): ATTR_MODE: self._config[CONF_MODE], } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if self._current_value is not None: return state = await self.async_get_last_state() - value = state and state.state + value: str | None = state and state.state # type: ignore[assignment] # Check against None because value can be 0 if value is not None and self._minimum <= len(value) <= self._maximum: self._current_value = value - async def async_set_value(self, value): + async def async_set_value(self, value: str) -> None: """Select new value.""" if len(value) < self._minimum or len(value) > self._maximum: _LOGGER.warning( diff --git a/mypy.ini b/mypy.ini index 92b96e75659..41a02600d94 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1561,6 +1561,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.input_text.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.integration.*] check_untyped_defs = true disallow_incomplete_defs = true From 5f09503cf3a505140691e66a0e7fbf0da0158764 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:35:51 +0100 Subject: [PATCH 118/982] Fix client id label in ViCare integration (#103111) --- homeassistant/components/vicare/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 0700d5d6f0e..056a4df7920 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -3,11 +3,11 @@ "flow_title": "{name} ({host})", "step": { "user": { - "description": "Set up ViCare integration. To generate API key go to https://developer.viessmann.com", + "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", - "client_id": "[%key:common::config_flow::data::api_key%]", + "client_id": "Client ID", "heating_type": "Heating type" } } From 55a4769172e9bee7f0e1964a9dd4f1cd671e205f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Oct 2023 11:32:17 +0100 Subject: [PATCH 119/982] Abort config flow if Google Tasks API is not enabled (#103114) Co-authored-by: Martin Hjelmare --- .../components/google_tasks/config_flow.py | 28 ++++ .../components/google_tasks/strings.json | 4 +- .../fixtures/api_not_enabled_response.json | 15 +++ .../google_tasks/test_config_flow.py | 123 +++++++++++++++++- 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 tests/components/google_tasks/fixtures/api_not_enabled_response.json diff --git a/homeassistant/components/google_tasks/config_flow.py b/homeassistant/components/google_tasks/config_flow.py index 77570f0377f..b8e5e26f42c 100644 --- a/homeassistant/components/google_tasks/config_flow.py +++ b/homeassistant/components/google_tasks/config_flow.py @@ -2,6 +2,13 @@ import logging from typing import Any +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from googleapiclient.http import HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -28,3 +35,24 @@ class OAuth2FlowHandler( "access_type": "offline", "prompt": "consent", } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow.""" + try: + resource = build( + "tasks", + "v1", + credentials=Credentials(token=data[CONF_TOKEN][CONF_ACCESS_TOKEN]), + ) + cmd: HttpRequest = resource.tasklists().list() + await self.hass.async_add_executor_job(cmd.execute) + except HttpError as ex: + error = ex.reason + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": error}, + ) + except Exception as ex: # pylint: disable=broad-except + self.logger.exception("Unknown error occurred: %s", ex) + return self.async_abort(reason="unknown") + return self.async_create_entry(title=self.flow_impl.name, data=data) diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index e7dbbc2b625..f15c31f42d4 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -15,7 +15,9 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/google_tasks/fixtures/api_not_enabled_response.json b/tests/components/google_tasks/fixtures/api_not_enabled_response.json new file mode 100644 index 00000000000..75ecfddab20 --- /dev/null +++ b/tests/components/google_tasks/fixtures/api_not_enabled_response.json @@ -0,0 +1,15 @@ +{ + "error": { + "code": 403, + "message": "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "errors": [ + { + "message": "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "domain": "usageLimits", + "reason": "accessNotConfigured", + "extendedHelp": "https://console.developers.google.com" + } + ], + "status": "PERMISSION_DENIED" + } +} diff --git a/tests/components/google_tasks/test_config_flow.py b/tests/components/google_tasks/test_config_flow.py index b05e1eb108d..e92da605697 100644 --- a/tests/components/google_tasks/test_config_flow.py +++ b/tests/components/google_tasks/test_config_flow.py @@ -2,6 +2,9 @@ from unittest.mock import patch +from googleapiclient.errors import HttpError +from httplib2 import Response + from homeassistant import config_entries from homeassistant.components.google_tasks.const import ( DOMAIN, @@ -9,8 +12,11 @@ from homeassistant.components.google_tasks.const import ( OAUTH2_TOKEN, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from tests.common import load_fixture + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -59,8 +65,119 @@ async def test_full_flow( with patch( "homeassistant.components.google_tasks.async_setup_entry", return_value=True - ) as mock_setup: - await hass.config_entries.flow.async_configure(result["flow_id"]) - + ) as mock_setup, patch("homeassistant.components.google_tasks.config_flow.build"): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 + + +async def test_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check flow aborts if api is not enabled.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.config_flow.build", + side_effect=HttpError( + Response({"status": "403"}), + bytes(load_fixture("google_tasks/api_not_enabled_response.json"), "utf-8"), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "access_not_configured" + assert ( + result["description_placeholders"]["message"] + == "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + ) + + +async def test_general_exception( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check flow aborts if exception happens.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.config_flow.build", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From 193ce08b390b1a1c056720ddd758e65081e4cd7c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 31 Oct 2023 11:35:09 +0100 Subject: [PATCH 120/982] No aliases in workday (#103091) --- homeassistant/components/workday/config_flow.py | 4 ++-- homeassistant/components/workday/repairs.py | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 907f5c5bdb5..c4b1f1ba3fd 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -54,7 +54,7 @@ def add_province_to_schema( if not country: return schema - all_countries = list_supported_countries() + all_countries = list_supported_countries(include_aliases=False) if not all_countries.get(country): return schema @@ -117,7 +117,7 @@ DATA_SCHEMA_SETUP = vol.Schema( vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Optional(CONF_COUNTRY): CountrySelector( CountrySelectorConfig( - countries=list(list_supported_countries()), + countries=list(list_supported_countries(include_aliases=False)), ) ), } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index daafd0396b8..fbed179763e 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -43,7 +43,7 @@ class CountryFixFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the country step of a fix flow.""" if user_input is not None: - all_countries = list_supported_countries() + all_countries = list_supported_countries(include_aliases=False) if not all_countries[user_input[CONF_COUNTRY]]: options = dict(self.entry.options) new_options = {**options, **user_input, CONF_PROVINCE: None} @@ -61,7 +61,9 @@ class CountryFixFlow(RepairsFlow): { vol.Required(CONF_COUNTRY): SelectSelector( SelectSelectorConfig( - options=sorted(list_supported_countries()), + options=sorted( + list_supported_countries(include_aliases=False) + ), mode=SelectSelectorMode.DROPDOWN, ) ) @@ -83,7 +85,9 @@ class CountryFixFlow(RepairsFlow): return self.async_create_entry(data={}) assert self.country - country_provinces = list_supported_countries()[self.country] + country_provinces = list_supported_countries(include_aliases=False)[ + self.country + ] return self.async_show_form( step_id="province", data_schema=vol.Schema( From 22126a1280af23031f1b691313b550330b141328 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 31 Oct 2023 13:42:52 +0100 Subject: [PATCH 121/982] Handle exception introduced with recent PyViCare update (#103110) --- .../components/vicare/config_flow.py | 7 +++++-- tests/components/vicare/test_config_flow.py | 21 ++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index a0feb8f38ea..5b2d3afa427 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -4,7 +4,10 @@ from __future__ import annotations import logging from typing import Any -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) import voluptuous as vol from homeassistant import config_entries @@ -53,7 +56,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job( vicare_login, self.hass, user_input ) - except PyViCareInvalidCredentialsError: + except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError): errors["base"] = "invalid_auth" else: return self.async_create_entry(title=VICARE_NAME, data=user_input) diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 0774848ef11..7f70c13f0b0 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -2,7 +2,10 @@ from unittest.mock import AsyncMock, patch import pytest -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) from syrupy.assertion import SnapshotAssertion from homeassistant.components import dhcp @@ -43,6 +46,22 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {} + # test PyViCareInvalidConfigurationError + with patch( + f"{MODULE}.config_flow.vicare_login", + side_effect=PyViCareInvalidConfigurationError( + {"error": "foo", "error_description": "bar"} + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + # test PyViCareInvalidCredentialsError with patch( f"{MODULE}.config_flow.vicare_login", From 620a3350d7e5cd45d87ed76de869676b95b7fb98 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 31 Oct 2023 15:15:20 +0100 Subject: [PATCH 122/982] Bump reolink-aio to 0.7.12 (#103120) --- 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 9d9d8d59e88..1c1d8dd96b1 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.7.11"] + "requirements": ["reolink-aio==0.7.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 770ce521085..280598927bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2319,7 +2319,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.11 +reolink-aio==0.7.12 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd088c89e5b..9f54b057a4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.11 +reolink-aio==0.7.12 # homeassistant.components.rflink rflink==0.0.65 From f55cd17982da2dd44d180dcd33aa42edd1b2fbfb Mon Sep 17 00:00:00 2001 From: Narmishka <73436375+Narmishka@users.noreply.github.com> Date: Tue, 31 Oct 2023 16:50:17 +0100 Subject: [PATCH 123/982] Move Freebox tests to fixtures (#103128) --- tests/components/freebox/const.py | 2893 +---------------- .../freebox/fixtures/call_get_calls_log.json | 35 + .../fixtures/connection_get_status.json | 14 + .../fixtures/home_alarm_get_values.json | 5 + .../freebox/fixtures/home_get_nodes.json | 2545 +++++++++++++++ .../freebox/fixtures/home_pir_get_values.json | 14 + .../freebox/fixtures/lan_get_hosts_list.json | 274 ++ .../freebox/fixtures/storage_get_disks.json | 109 + .../freebox/fixtures/storage_get_raids.json | 64 + .../freebox/fixtures/system_get_config.json | 57 + .../fixtures/wifi_get_global_config.json | 4 + 11 files changed, 3145 insertions(+), 2869 deletions(-) create mode 100644 tests/components/freebox/fixtures/call_get_calls_log.json create mode 100644 tests/components/freebox/fixtures/connection_get_status.json create mode 100644 tests/components/freebox/fixtures/home_alarm_get_values.json create mode 100644 tests/components/freebox/fixtures/home_get_nodes.json create mode 100644 tests/components/freebox/fixtures/home_pir_get_values.json create mode 100644 tests/components/freebox/fixtures/lan_get_hosts_list.json create mode 100644 tests/components/freebox/fixtures/storage_get_disks.json create mode 100644 tests/components/freebox/fixtures/storage_get_raids.json create mode 100644 tests/components/freebox/fixtures/system_get_config.json create mode 100644 tests/components/freebox/fixtures/wifi_get_global_config.json diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 0cd854b22bf..c2951edf8bb 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -1,2888 +1,43 @@ """Test constants.""" +from homeassistant.components.freebox.const import DOMAIN + +from tests.common import load_json_object_fixture + MOCK_HOST = "myrouter.freeboxos.fr" MOCK_PORT = 1234 # router -DATA_SYSTEM_GET_CONFIG = { - "mac": "68:A3:78:00:00:00", - "model_info": { - "has_ext_telephony": True, - "has_speakers_jack": True, - "wifi_type": "2d4_5g", - "pretty_name": "Freebox Server (r2)", - "customer_hdd_slots": 0, - "name": "fbxgw-r2/full", - "has_speakers": True, - "internal_hdd_size": 250, - "has_femtocell_exp": True, - "has_internal_hdd": True, - "has_dect": True, - }, - "fans": [{"id": "fan0_speed", "name": "Ventilateur 1", "value": 2130}], - "sensors": [ - {"id": "temp_hdd", "name": "Disque dur", "value": 40}, - {"id": "temp_hdd2", "name": "Disque dur 2"}, - {"id": "temp_sw", "name": "Température Switch", "value": 50}, - {"id": "temp_cpum", "name": "Température CPU M", "value": 60}, - {"id": "temp_cpub", "name": "Température CPU B", "value": 56}, - ], - "board_name": "fbxgw2r", - "disk_status": "active", - "uptime": "156 jours 19 heures 56 minutes 16 secondes", - "uptime_val": 13550176, - "user_main_storage": "Disque dur", - "box_authenticated": True, - "serial": "762601T190510709", - "firmware_version": "4.2.5", -} +DATA_SYSTEM_GET_CONFIG = load_json_object_fixture("system_get_config.json", DOMAIN) # sensors -DATA_CONNECTION_GET_STATUS = { - "type": "ethernet", - "rate_down": 198900, - "bytes_up": 12035728872949, - "ipv4_port_range": [0, 65535], - "rate_up": 1440000, - "bandwidth_up": 700000000, - "ipv6": "2a01:e35:ffff:ffff::1", - "bandwidth_down": 1000000000, - "media": "ftth", - "state": "up", - "bytes_down": 2355966141297, - "ipv4": "82.67.00.00", -} +DATA_CONNECTION_GET_STATUS = load_json_object_fixture( + "connection_get_status.json", DOMAIN +) -DATA_CALL_GET_CALLS_LOG = [ - { - "number": "0988290475", - "type": "missed", - "id": 94, - "duration": 15, - "datetime": 1613752718, - "contact_id": 0, - "line_id": 0, - "name": "0988290475", - "new": True, - }, - { - "number": "0367250217", - "type": "missed", - "id": 93, - "duration": 25, - "datetime": 1613662328, - "contact_id": 0, - "line_id": 0, - "name": "0367250217", - "new": True, - }, - { - "number": "0184726018", - "type": "missed", - "id": 92, - "duration": 25, - "datetime": 1613225098, - "contact_id": 0, - "line_id": 0, - "name": "0184726018", - "new": True, - }, -] +DATA_CALL_GET_CALLS_LOG = load_json_object_fixture("call_get_calls_log.json", DOMAIN) -DATA_STORAGE_GET_DISKS = [ - { - "idle_duration": 0, - "read_error_requests": 0, - "read_requests": 1815106, - "spinning": True, - "table_type": "raid", - "firmware": "0001", - "type": "sata", - "idle": True, - "connector": 2, - "id": 1000, - "write_error_requests": 0, - "time_before_spindown": 600, - "state": "disabled", - "write_requests": 80386151, - "total_bytes": 2000000000000, - "model": "ST2000LM015-2E8174", - "active_duration": 0, - "temp": 30, - "serial": "ZDZLBFHC", - "partitions": [ - { - "fstype": "raid", - "total_bytes": 0, - "label": "Volume 2000Go", - "id": 1000, - "internal": False, - "fsck_result": "no_run_yet", - "state": "umounted", - "disk_id": 1000, - "free_bytes": 0, - "used_bytes": 0, - "path": "L1ZvbHVtZSAyMDAwR28=", - } - ], - }, - { - "idle_duration": 0, - "read_error_requests": 0, - "read_requests": 3622038, - "spinning": True, - "table_type": "raid", - "firmware": "0001", - "type": "sata", - "idle": True, - "connector": 0, - "id": 2000, - "write_error_requests": 0, - "time_before_spindown": 600, - "state": "disabled", - "write_requests": 80386151, - "total_bytes": 2000000000000, - "model": "ST2000LM015-2E8174", - "active_duration": 0, - "temp": 31, - "serial": "ZDZLEJXE", - "partitions": [ - { - "fstype": "raid", - "total_bytes": 0, - "label": "Volume 2000Go 1", - "id": 2000, - "internal": False, - "fsck_result": "no_run_yet", - "state": "umounted", - "disk_id": 2000, - "free_bytes": 0, - "used_bytes": 0, - "path": "L1ZvbHVtZSAyMDAwR28gMQ==", - } - ], - }, - { - "idle_duration": 0, - "read_error_requests": 0, - "read_requests": 0, - "spinning": False, - "table_type": "superfloppy", - "firmware": "", - "type": "raid", - "idle": False, - "connector": 0, - "id": 3000, - "write_error_requests": 0, - "state": "enabled", - "write_requests": 0, - "total_bytes": 2000000000000, - "model": "", - "active_duration": 0, - "temp": 0, - "serial": "", - "partitions": [ - { - "fstype": "ext4", - "total_bytes": 1960000000000, - "label": "Freebox", - "id": 3000, - "internal": False, - "fsck_result": "no_run_yet", - "state": "mounted", - "disk_id": 3000, - "free_bytes": 1730000000000, - "used_bytes": 236910000000, - "path": "L0ZyZWVib3g=", - } - ], - }, -] +DATA_STORAGE_GET_DISKS = load_json_object_fixture("storage_get_disks.json", DOMAIN) -DATA_STORAGE_GET_RAIDS = [ - { - "degraded": False, - "raid_disks": 2, # Number of members that should be in this array - "next_check": 0, # Unix timestamp of next check in seconds. Might be 0 if check_interval is 0 - "sync_action": "idle", # values: idle, resync, recover, check, repair, reshape, frozen - "level": "raid1", # values: basic, raid0, raid1, raid5, raid10 - "uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", - "sysfs_state": "clear", # values: clear, inactive, suspended, readonly, read_auto, clean, active, write_pending, active_idle - "id": 0, - "sync_completed_pos": 0, # Current position of sync process - "members": [ - { - "total_bytes": 2000000000000, - "active_device": 1, - "id": 1000, - "corrected_read_errors": 0, - "array_id": 0, - "disk": { - "firmware": "0001", - "temp": 29, - "serial": "ZDZLBFHC", - "model": "ST2000LM015-2E8174", - }, - "role": "active", # values: active, faulty, spare, missing - "sct_erc_supported": False, - "sct_erc_enabled": False, - "dev_uuid": "fca8720e-13f9-11ee-9106-38d547790df8", - "device_location": "sata-internal-p2", - "set_name": "Freebox", - "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", - }, - { - "total_bytes": 2000000000000, - "active_device": 0, - "id": 2000, - "corrected_read_errors": 0, - "array_id": 0, - "disk": { - "firmware": "0001", - "temp": 30, - "serial": "ZDZLEJXE", - "model": "ST2000LM015-2E8174", - }, - "role": "active", - "sct_erc_supported": False, - "sct_erc_enabled": False, - "dev_uuid": "16bf00d6-13fa-11ee-9106-38d547790df8", - "device_location": "sata-internal-p0", - "set_name": "Freebox", - "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", - }, - ], - "array_size": 2000000000000, # Size of array in bytes - "state": "running", # stopped, running, error - "sync_speed": 0, # Sync speed in bytes per second - "name": "Freebox", - "check_interval": 0, # Check interval in seconds - "disk_id": 3000, - "last_check": 1682884357, # Unix timestamp of last check in seconds - "sync_completed_end": 0, # End position of sync process: total of bytes to sync - "sync_completed_percent": 0, # Percentage of sync completion - } -] +DATA_STORAGE_GET_RAIDS = load_json_object_fixture("storage_get_raids.json", DOMAIN) # switch -WIFI_GET_GLOBAL_CONFIG = {"enabled": True, "mac_filter_state": "disabled"} +WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture("wifi_get_global_config.json", DOMAIN) # device_tracker -DATA_LAN_GET_HOSTS_LIST = [ - { - "l2ident": {"id": "8C:97:EA:00:00:00", "type": "mac_address"}, - "active": True, - "persistent": False, - "names": [ - {"name": "d633d0c8-958c-43cc-e807-d881b076924b", "source": "mdns"}, - {"name": "Freebox Player POP", "source": "mdns_srv"}, - ], - "vendor_name": "Freebox SAS", - "host_type": "smartphone", - "interface": "pub", - "id": "ether-8c:97:ea:00:00:00", - "last_time_reachable": 1614107652, - "primary_name_manual": False, - "l3connectivities": [ - { - "addr": "192.168.1.180", - "active": True, - "reachable": True, - "last_activity": 1614107614, - "af": "ipv4", - "last_time_reachable": 1614104242, - }, - { - "addr": "fe80::dcef:dbba:6604:31d1", - "active": True, - "reachable": True, - "last_activity": 1614107645, - "af": "ipv6", - "last_time_reachable": 1614107645, - }, - { - "addr": "2a01:e34:eda1:eb40:8102:4704:7ce0:2ace", - "active": False, - "reachable": False, - "last_activity": 1611574428, - "af": "ipv6", - "last_time_reachable": 1611574428, - }, - { - "addr": "2a01:e34:eda1:eb40:c8e5:c524:c96d:5f5e", - "active": False, - "reachable": False, - "last_activity": 1612475101, - "af": "ipv6", - "last_time_reachable": 1612475101, - }, - { - "addr": "2a01:e34:eda1:eb40:583a:49df:1df0:c2df", - "active": True, - "reachable": True, - "last_activity": 1614107652, - "af": "ipv6", - "last_time_reachable": 1614107652, - }, - { - "addr": "2a01:e34:eda1:eb40:147e:3569:86ab:6aaa", - "active": False, - "reachable": False, - "last_activity": 1612486752, - "af": "ipv6", - "last_time_reachable": 1612486752, - }, - ], - "default_name": "Freebox Player POP", - "model": "fbx8am", - "reachable": True, - "last_activity": 1614107652, - "primary_name": "Freebox Player POP", - }, - { - "l2ident": {"id": "DE:00:B0:00:00:00", "type": "mac_address"}, - "active": False, - "persistent": False, - "vendor_name": "", - "host_type": "workstation", - "interface": "pub", - "id": "ether-de:00:b0:00:00:00", - "last_time_reachable": 1607125599, - "primary_name_manual": False, - "default_name": "", - "l3connectivities": [ - { - "addr": "192.168.1.181", - "active": False, - "reachable": False, - "last_activity": 1607125599, - "af": "ipv4", - "last_time_reachable": 1607125599, - }, - { - "addr": "192.168.1.182", - "active": False, - "reachable": False, - "last_activity": 1605958758, - "af": "ipv4", - "last_time_reachable": 1605958758, - }, - { - "addr": "2a01:e34:eda1:eb40:dc00:b0ff:fedf:e30", - "active": False, - "reachable": False, - "last_activity": 1607125594, - "af": "ipv6", - "last_time_reachable": 1607125594, - }, - ], - "reachable": False, - "last_activity": 1607125599, - "primary_name": "", - }, - { - "l2ident": {"id": "DC:00:B0:00:00:00", "type": "mac_address"}, - "active": True, - "persistent": False, - "names": [ - {"name": "Repeteur-Wifi-Freebox", "source": "mdns"}, - {"name": "Repeteur Wifi Freebox", "source": "mdns_srv"}, - ], - "vendor_name": "", - "host_type": "freebox_wifi", - "interface": "pub", - "id": "ether-dc:00:b0:00:00:00", - "last_time_reachable": 1614107678, - "primary_name_manual": False, - "l3connectivities": [ - { - "addr": "192.168.1.145", - "active": True, - "reachable": True, - "last_activity": 1614107678, - "af": "ipv4", - "last_time_reachable": 1614107678, - }, - { - "addr": "fe80::de00:b0ff:fe52:6ef6", - "active": True, - "reachable": True, - "last_activity": 1614107608, - "af": "ipv6", - "last_time_reachable": 1614107603, - }, - { - "addr": "2a01:e34:eda1:eb40:de00:b0ff:fe52:6ef6", - "active": True, - "reachable": True, - "last_activity": 1614107618, - "af": "ipv6", - "last_time_reachable": 1614107618, - }, - ], - "default_name": "Repeteur Wifi Freebox", - "model": "fbxwmr", - "reachable": True, - "last_activity": 1614107678, - "primary_name": "Repeteur Wifi Freebox", - }, - { - "l2ident": {"id": "5E:65:55:00:00:00", "type": "mac_address"}, - "active": False, - "persistent": False, - "names": [ - {"name": "iPhoneofQuentin", "source": "dhcp"}, - {"name": "iPhone-of-Quentin", "source": "mdns"}, - ], - "vendor_name": "", - "host_type": "smartphone", - "interface": "pub", - "id": "ether-5e:65:55:00:00:00", - "last_time_reachable": 1612611982, - "primary_name_manual": False, - "default_name": "iPhonedeQuentin", - "l3connectivities": [ - { - "addr": "192.168.1.148", - "active": False, - "reachable": False, - "last_activity": 1612611973, - "af": "ipv4", - "last_time_reachable": 1612611973, - }, - { - "addr": "fe80::14ca:6c30:938b:e281", - "active": False, - "reachable": False, - "last_activity": 1609693223, - "af": "ipv6", - "last_time_reachable": 1609693223, - }, - { - "addr": "fe80::1c90:2b94:1ba2:bd8b", - "active": False, - "reachable": False, - "last_activity": 1610797303, - "af": "ipv6", - "last_time_reachable": 1610797303, - }, - { - "addr": "fe80::8c8:e58b:838e:6785", - "active": False, - "reachable": False, - "last_activity": 1612611951, - "af": "ipv6", - "last_time_reachable": 1612611946, - }, - { - "addr": "2a01:e34:eda1:eb40:f0e7:e198:3a69:58", - "active": False, - "reachable": False, - "last_activity": 1609693245, - "af": "ipv6", - "last_time_reachable": 1609693245, - }, - { - "addr": "2a01:e34:eda1:eb40:1dc4:c6f8:aa20:c83b", - "active": False, - "reachable": False, - "last_activity": 1610797176, - "af": "ipv6", - "last_time_reachable": 1610797176, - }, - { - "addr": "2a01:e34:eda1:eb40:6cf6:5811:1770:c662", - "active": False, - "reachable": False, - "last_activity": 1612611982, - "af": "ipv6", - "last_time_reachable": 1612611982, - }, - { - "addr": "2a01:e34:eda1:eb40:438:9b2c:4f8f:f48a", - "active": False, - "reachable": False, - "last_activity": 1612611946, - "af": "ipv6", - "last_time_reachable": 1612611946, - }, - ], - "reachable": False, - "last_activity": 1612611982, - "primary_name": "iPhoneofQuentin", - }, -] - -# Home -# PIR node id 26, endpoint id 6 -DATA_HOME_PIR_GET_VALUES = { - "category": "", - "ep_type": "signal", - "id": 6, - "label": "Détection", - "name": "trigger", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", -} - -# Home -# ALARM node id 7, endpoint id 11 -DATA_HOME_ALARM_GET_VALUES = { - "refresh": 2000, - "value": "alarm2_armed", - "value_type": "string", -} +DATA_LAN_GET_HOSTS_LIST = load_json_object_fixture("lan_get_hosts_list.json", DOMAIN) # Home # ALL -DATA_HOME_GET_NODES = [ - { - "adapter": 2, - "area": 38, - "category": "camera", - "group": {"label": "Salon"}, - "id": 16, - "label": "Caméra II", - "name": "node_16", - "props": { - "Ip": "192.169.0.2", - "Login": "camfreebox", - "Mac": "34:2d:f2:e5:9d:ff", - "Pass": "xxxxx", - "Stream": "http://freeboxcam:mv...tream.m3u8", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Détection", - "name": "detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Activé avec l'alarme", - "name": "activation", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Haute qualité vidéo", - "name": "quality", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 3, - "label": "Sensibilité", - "name": "sensitivity", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 4, - "label": "Seuil", - "name": "threshold", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Retourner verticalement", - "name": "flip", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Horodatage", - "name": "timestamp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Volume du micro", - "name": "volume", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 9, - "label": "Détection de bruit", - "name": "sound_detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 10, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 11, - "label": "Flux rtsp", - "name": "rtsp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 12, - "label": "Emplacement des vidéos", - "name": "disk", - "ui": {"access": "rw", "display": "disk"}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 13, - "label": "Détection ", - "name": "detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/xxxx.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Activé avec l'alarme", - "name": "activation", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/alert_toggle.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Haute qualité vidéo", - "name": "quality", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Flux.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 16, - "label": "Sensibilité", - "name": "sensitivity", - "refresh": 2000, - "ui": { - "access": "r", - "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 17, - "label": "Seuil", - "name": "threshold", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 2, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 18, - "label": "Retourner verticalement", - "name": "flip", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Retour.png", - }, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Horodatage", - "name": "timestamp", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Horloge.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 20, - "label": "Volume du micro", - "name": "volume", - "refresh": 2000, - "ui": { - "access": "r", - "display": "slider", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - "range": [...], - }, - "value": 100, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 22, - "label": "Détection de bruit", - "name": "sound_detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 23, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 24, - "label": "Flux rtsp", - "name": "rtsp", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", - "display": "toggle", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 25, - "label": "Niveau de réception", - "name": "rssi", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/reception_%.png", - "range": [...], - "status_text_range": [...], - "unit": "dB", - }, - "value": -75, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 26, - "label": "Emplacement des vidéos", - "name": "disk", - "refresh": 2000, - "ui": { - "access": "r", - "display": "disk", - "icon_url": "/resources/images/home/pictos/directory.png", - }, - "value": "Freebox", - "value_type": "string", - "visibility": "normal", - }, - ], - "signal_links": [], - "slot_links": [{...}], - "status": "active", - "type": { - "abstract": False, - "endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Détection ", - "name": "detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Activé avec l'alarme", - "name": "activation", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Haute qualité vidéo", - "name": "quality", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 3, - "label": "Sensibilité", - "name": "sensitivity", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 4, - "label": "Seuil", - "name": "threshold", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Retourner verticalement", - "name": "flip", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Horodatage", - "name": "timestamp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Volume du micro", - "name": "volume", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 9, - "label": "Détection de bruit", - "name": "sound_detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 10, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 11, - "label": "Flux rtsp", - "name": "rtsp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 12, - "label": "Emplacement des vidéos", - "name": "disk", - "ui": {"access": "rw", "display": "disk"}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 13, - "label": "Détection ", - "name": "detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/xxxx.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Activé avec l'alarme", - "name": "activation", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/alert_toggle.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Haute qualité vidéo", - "name": "quality", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Flux.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 16, - "label": "Sensibilité", - "name": "sensitivity", - "refresh": 2000, - "ui": { - "access": "r", - "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 17, - "label": "Seuil", - "name": "threshold", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 2, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 18, - "label": "Retourner verticalement", - "name": "flip", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Retour.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Horodatage", - "name": "timestamp", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Horloge.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 20, - "label": "Volume du micro", - "name": "volume", - "refresh": 2000, - "ui": { - "access": "r", - "display": "slider", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - "range": [...], - }, - "value": 80, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 22, - "label": "Détection de bruit", - "name": "sound_detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 23, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 24, - "label": "Flux rtsp", - "name": "rtsp", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", - "display": "toggle", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 25, - "label": "Niveau de réception", - "name": "rssi", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/reception_%.png", - "range": [...], - "status_text_range": [...], - "unit": "dB", - }, - "value": -49, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 26, - "label": "Emplacement des vidéos", - "name": "disk", - "refresh": 2000, - "ui": { - "access": "r", - "display": "disk", - "icon_url": "/resources/images/home/pictos/directory.png", - }, - "value": "Freebox", - "value_type": "string", - "visibility": "normal", - }, - ], - "generic": False, - "icon": "/resources/images/ho...camera.png", - "inherit": "node::cam", - "label": "Caméra Freebox", - "name": "node::cam::freebox", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 1, - "area": 38, - "category": "camera", - "group": {"label": "Salon"}, - "id": 15, - "label": "Caméra I", - "name": "node_15", - "props": { - "Ip": "192.169.0.2", - "Login": "camfreebox", - "Mac": "34:2d:f2:e5:9d:ff", - "Pass": "xxxxx", - "Stream": "http://freeboxcam:mv...tream.m3u8", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Détection ", - "name": "detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Activé avec l'alarme", - "name": "activation", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Haute qualité vidéo", - "name": "quality", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 3, - "label": "Sensibilité", - "name": "sensitivity", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 4, - "label": "Seuil", - "name": "threshold", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Retourner verticalement", - "name": "flip", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Horodatage", - "name": "timestamp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Volume du micro", - "name": "volume", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 9, - "label": "Détection de bruit", - "name": "sound_detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 10, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 11, - "label": "Flux rtsp", - "name": "rtsp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 12, - "label": "Emplacement des vidéos", - "name": "disk", - "ui": {"access": "rw", "display": "disk"}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 13, - "label": "Détection ", - "name": "detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/xxxx.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Activé avec l'alarme", - "name": "activation", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/alert_toggle.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Haute qualité vidéo", - "name": "quality", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Flux.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 16, - "label": "Sensibilité", - "name": "sensitivity", - "refresh": 2000, - "ui": { - "access": "r", - "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 17, - "label": "Seuil", - "name": "threshold", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 2, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 18, - "label": "Retourner verticalement", - "name": "flip", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Retour.png", - }, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Horodatage", - "name": "timestamp", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Horloge.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 20, - "label": "Volume du micro", - "name": "volume", - "refresh": 2000, - "ui": { - "access": "r", - "display": "slider", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - "range": [...], - }, - "value": 100, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 22, - "label": "Détection de bruit", - "name": "sound_detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 23, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 24, - "label": "Flux rtsp", - "name": "rtsp", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", - "display": "toggle", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 25, - "label": "Niveau de réception", - "name": "rssi", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/reception_%.png", - "range": [...], - "status_text_range": [...], - "unit": "dB", - }, - "value": -75, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 26, - "label": "Emplacement des vidéos", - "name": "disk", - "refresh": 2000, - "ui": { - "access": "r", - "display": "disk", - "icon_url": "/resources/images/home/pictos/directory.png", - }, - "value": "Freebox", - "value_type": "string", - "visibility": "normal", - }, - ], - "signal_links": [], - "slot_links": [{...}], - "status": "active", - "type": { - "abstract": False, - "endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Détection ", - "name": "detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Activé avec l'alarme", - "name": "activation", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Haute qualité vidéo", - "name": "quality", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 3, - "label": "Sensibilité", - "name": "sensitivity", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 4, - "label": "Seuil", - "name": "threshold", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Retourner verticalement", - "name": "flip", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Horodatage", - "name": "timestamp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Volume du micro", - "name": "volume", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 9, - "label": "Détection de bruit", - "name": "sound_detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 10, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 11, - "label": "Flux rtsp", - "name": "rtsp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 12, - "label": "Emplacement des vidéos", - "name": "disk", - "ui": {"access": "rw", "display": "disk"}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 13, - "label": "Détection ", - "name": "detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/xxxx.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Activé avec l'alarme", - "name": "activation", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/alert_toggle.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Haute qualité vidéo", - "name": "quality", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Flux.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 16, - "label": "Sensibilité", - "name": "sensitivity", - "refresh": 2000, - "ui": { - "access": "r", - "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 17, - "label": "Seuil", - "name": "threshold", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 2, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 18, - "label": "Retourner verticalement", - "name": "flip", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Retour.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Horodatage", - "name": "timestamp", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Horloge.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 20, - "label": "Volume du micro", - "name": "volume", - "refresh": 2000, - "ui": { - "access": "r", - "display": "slider", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - "range": [...], - }, - "value": 80, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 22, - "label": "Détection de bruit", - "name": "sound_detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 23, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 24, - "label": "Flux rtsp", - "name": "rtsp", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", - "display": "toggle", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 25, - "label": "Niveau de réception", - "name": "rssi", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/reception_%.png", - "range": [...], - "status_text_range": [...], - "unit": "dB", - }, - "value": -49, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 26, - "label": "Emplacement des vidéos", - "name": "disk", - "refresh": 2000, - "ui": { - "access": "r", - "display": "disk", - "icon_url": "/resources/images/home/pictos/directory.png", - }, - "value": "Freebox", - "value_type": "string", - "visibility": "normal", - }, - ], - "generic": False, - "icon": "/resources/images/ho...camera.png", - "inherit": "node::cam", - "label": "Caméra Freebox", - "name": "node::cam::freebox", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 5, - "category": "kfb", - "group": {"label": ""}, - "id": 9, - "label": "Télécommande", - "name": "node_9", - "props": { - "Address": 5, - "Challenge": "65ae6b4def41f3e3a5a77ec63e988", - "FwVersion": 29798318, - "Gateway": 1, - "ItemId": "e76c2b75a4a6e2", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Activé", - "name": "enable", - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 1, - "label": "Activé", - "name": "enable", - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 2, - "label": "Bouton appuyé", - "name": "pushed", - "value": None, - "value_type": "int", - }, - { - "category": "", - "ep_type": "signal", - "id": 3, - "label": "Niveau de Batterie", - "name": "battery", - "refresh": 2000, - "value": 100, - "value_type": "int", - }, - ], - "signal_links": [ - { - "adapter": 5, - "category": "alarm", - "id": 7, - "label": "Système d alarme", - "link_id": 10, - "name": "node_7", - "status": "active", - }, - ], - "slot_links": [], - "status": "active", - "type": { - "abstract": False, - "endpoints": [...], - "generic": False, - "icon": "/resources/images/home/pictos/telecommande.png", - "inherit": "node::domus", - "label": "Télécommande pour alarme", - "name": "node::domus::sercomm::keyfob", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 5, - "area": 40, - "category": "dws", - "group": {"label": "Entrée"}, - "id": 11, - "label": "Ouverture porte", - "name": "node_11", - "props": { - "Address": 6, - "Challenge": "964a2dddf2c40c3e2384f66d2", - "FwVersion": 29798220, - "Gateway": 1, - "ItemId": "9eff759dd553de7", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Alarme principale", - "name": "alarm1", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Alarme secondaire", - "name": "alarm2", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Zone temporisée", - "name": "timed", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 7, - "label": "Couvercle", - "name": "cover", - "refresh": 2000, - "ui": { - "access": "r", - "display": "warning", - "icon_url": "/resources/images/home/pictos/warning.png", - }, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 8, - "label": "Niveau de Batterie", - "name": "battery", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/batt_%.png", - "range": [...], - "status_text_range": [...], - "unit": "%", - }, - "value": 100, - "value_type": "int", - "visibility": "normal", - }, - ], - "signal_links": [ - { - "adapter": 5, - "category": "alarm", - "id": 7, - "label": "Système d alarme", - "link_id": 12, - "name": "node_7", - "status": "active", - } - ], - "slot_links": [], - "status": "active", - "type": { - "abstract": False, - "endpoints": [ - { - "ep_type": "slot", - "id": 0, - "label": "Alarme principale", - "name": "alarm1", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 1, - "label": "Alarme secondaire", - "name": "alarm2", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 2, - "label": "Zone temporisée", - "name": "timed", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 3, - "label": "Alarme principale", - "name": "alarm1", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 4, - "label": "Alarme secondaire", - "name": "alarm2", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 5, - "label": "Zone temporisée", - "name": "timed", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 6, - "label": "Détection", - "name": "trigger", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 7, - "label": "Couvercle", - "name": "cover", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 8, - "label": "Niveau de Batterie", - "name": "1battery", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 9, - "label": "Batterie faible", - "name": "battery_warning", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 10, - "label": "Alarme", - "name": "alarm", - "param_type": "void", - "value_type": "void", - "visibility": "internal", - }, - ], - "generic": False, - "icon": "/resources/images/home/pictos/detecteur_ouverture.png", - "inherit": "node::domus", - "label": "Détecteur d'ouverture de porte", - "name": "node::domus::sercomm::doorswitch", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 5, - "area": 38, - "category": "pir", - "group": {"label": "Salon"}, - "id": 26, - "label": "Détecteur", - "name": "node_26", - "props": { - "Address": 9, - "Challenge": "ed2cc17f179862f5242256b3f597c367", - "FwVersion": 29871925, - "Gateway": 1, - "ItemId": "240d000f9fefe576", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Alarme principale", - "name": "alarm1", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Alarme secondaire", - "name": "alarm2", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Zone temporisée", - "name": "timed", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 6, - "label": "Détection", - "name": "trigger", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 7, - "label": "Couvercle", - "name": "cover", - "refresh": 2000, - "ui": { - "access": "r", - "display": "warning", - "icon_url": "/resources/images/home/pictos/warning.png", - }, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 8, - "label": "Niveau de Batterie", - "name": "battery", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/batt_x.png", - "status_text_range": [...], - "unit": "%", - }, - "value": 100, - "value_type": "int", - "visibility": "normal", - }, - ], - "signal_links": [ - { - "adapter": 5, - "category": "alarm", - "id": 7, - "label": "Système d alarme", - "link_id": 12, - "name": "node_7", - "status": "active", - } - ], - "slot_links": [], - "status": "active", - "type": { - "abstract": False, - "endpoints": [ - { - "ep_type": "slot", - "id": 0, - "label": "Alarme principale", - "name": "alarm1", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 1, - "label": "Alarme secondaire", - "name": "alarm2", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 2, - "label": "Zone temporisée", - "name": "timed", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 3, - "label": "Alarme principale", - "name": "alarm1", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 4, - "label": "Alarme secondaire", - "name": "alarm2", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 5, - "label": "Zone temporisée", - "name": "timed", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 6, - "label": "Détection", - "name": "trigger", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 7, - "label": "Couvercle", - "name": "cover", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 8, - "label": "Niveau de Batterie", - "name": "battery", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 9, - "label": "Batterie faible", - "name": "battery_warning", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 10, - "label": "Alarme", - "name": "alarm", - "param_type": "void", - "value_type": "void", - "visibility": "internal", - }, - ], - "generic": False, - "icon": "/resources/images/home/pictos/detecteur_xxxx.png", - "inherit": "node::domus", - "label": "Détecteur infrarouge", - "name": "node::domus::sercomm::pir", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 10, - "area": 38, - "category": "shutter", - "group": {"label": "Salon"}, - "id": 150, - "label": "Shutter 1", - "name": "node_150", - "type": { - "inherit": "node::trs", - }, - }, - { - "adapter": 11, - "area": 38, - "category": "shutter", - "group": {"label": "Salon"}, - "id": 151, - "label": "Shutter 2", - "name": "node_151", - "type": { - "inherit": "node::ios", - }, - }, - { - "adapter": 5, - "category": "alarm", - "group": {"label": ""}, - "id": 7, - "label": "Système d'alarme", - "name": "node_7", - "props": { - "Address": 3, - "Challenge": "447599f5cab8620122b913e55faf8e1d", - "FwVersion": 47396239, - "Gateway": 1, - "ItemId": "e515a55b04f32e6d", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Code PIN", - "name": "pin", - "ui": {...}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Puissance des bips", - "name": "sound", - "ui": {...}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Puissance de la sirène", - "name": "volume", - "ui": {...}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "alarm", - "ep_type": "slot", - "id": 8, - "label": "Délai avant armement", - "name": "timeout1", - "ui": {...}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "alarm", - "ep_type": "slot", - "id": 9, - "label": "Délai avant sirène", - "name": "timeout2", - "ui": {...}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "alarm", - "ep_type": "slot", - "id": 10, - "label": "Durée de la sirène", - "name": "timeout3", - "ui": {...}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 12, - "label": "Code PIN", - "name": "pin", - "refresh": 2000, - "ui": {...}, - "value": "0000", - "value_type": "string", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Puissance des bips", - "name": "sound", - "refresh": 2000, - "ui": {...}, - "value": 1, - "value_type": "int", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Puissance de la sirène", - "name": "volume", - "refresh": 2000, - "ui": {...}, - "value": 100, - "value_type": "int", - }, - { - "category": "alarm", - "ep_type": "signal", - "id": 16, - "label": "Délai avant armement", - "name": "timeout1", - "refresh": 2000, - "ui": {...}, - "value": 15, - "value_type": "int", - }, - { - "category": "alarm", - "ep_type": "signal", - "id": 17, - "label": "Délai avant sirène", - "name": "timeout2", - "refresh": 2000, - "ui": {...}, - "value": 15, - "value_type": "int", - }, - { - "category": "alarm", - "ep_type": "signal", - "id": 18, - "label": "Durée de la sirène", - "name": "timeout3", - "refresh": 2000, - "ui": {...}, - "value": 300, - "value_type": "int", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Niveau de Batterie", - "name": "battery", - "refresh": 2000, - "ui": {...}, - "value": 85, - "value_type": "int", - }, - ], - "type": { - "abstract": False, - "endpoints": [ - { - "ep_type": "slot", - "id": 0, - "label": "Trigger", - "name": "trigger", - "value_type": "void", - "visibility": "internal", - }, - { - "ep_type": "slot", - "id": 1, - "label": "Alarme principale", - "name": "alarm1", - "value_type": "void", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 2, - "label": "Alarme secondaire", - "name": "alarm2", - "value_type": "void", - "visibility": "internal", - }, - { - "ep_type": "slot", - "id": 3, - "label": "Passer le délai", - "name": "skip", - "value_type": "void", - "visibility": "internal", - }, - { - "ep_type": "slot", - "id": 4, - "label": "Désactiver l'alarme", - "name": "off", - "value_type": "void", - "visibility": "internal", - }, - { - "ep_type": "slot", - "id": 5, - "label": "Code PIN", - "name": "pin", - "value_type": "string", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 6, - "label": "Puissance des bips", - "name": "sound", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 7, - "label": "Puissance de la sirène", - "name": "volume", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 8, - "label": "Délai avant armement", - "name": "timeout1", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 9, - "label": "Délai avant sirène", - "name": "timeout2", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 10, - "label": "Durée de la sirène", - "name": "timeout3", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 11, - "label": "État de l'alarme", - "name": "state", - "param_type": "void", - "value_type": "string", - "visibility": "internal", - }, - { - "ep_type": "signal", - "id": 12, - "label": "Code PIN", - "name": "pin", - "param_type": "void", - "value_type": "string", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 13, - "label": "Erreur", - "name": "error", - "param_type": "void", - "value_type": "string", - "visibility": "internal", - }, - { - "ep_type": "signal", - "id": 14, - "label": "Puissance des bips", - "name": "sound", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 15, - "label": "Puissance de la sirène", - "name": "volume", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 16, - "label": "Délai avant armement", - "name": "timeout1", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 17, - "label": "Délai avant sirène", - "name": "timeout2", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 18, - "label": "Durée de la sirène", - "name": "timeout3", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 19, - "label": "Niveau de Batterie", - "name": "battery", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 20, - "label": "Batterie faible", - "name": "battery_warning", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - ], - "generic": False, - "icon": "/resources/images/home/pictos/alarm_system.png", - "inherit": "node::domus", - "label": "Système d'alarme", - "name": "node::domus::freebox::secmod", - "params": {}, - "physical": True, - }, - }, -] +DATA_HOME_GET_NODES = load_json_object_fixture("home_get_nodes.json", DOMAIN) + +# Home +# PIR node id 26, endpoint id 6 +DATA_HOME_PIR_GET_VALUES = load_json_object_fixture("home_pir_get_values.json", DOMAIN) + +# Home +# ALARM node id 7, endpoint id 11 +DATA_HOME_ALARM_GET_VALUES = load_json_object_fixture( + "home_alarm_get_values.json", DOMAIN +) diff --git a/tests/components/freebox/fixtures/call_get_calls_log.json b/tests/components/freebox/fixtures/call_get_calls_log.json new file mode 100644 index 00000000000..4ee641dc0c5 --- /dev/null +++ b/tests/components/freebox/fixtures/call_get_calls_log.json @@ -0,0 +1,35 @@ +[ + { + "number": "0988290475", + "type": "missed", + "id": 94, + "duration": 15, + "datetime": 1613752718, + "contact_id": 0, + "line_id": 0, + "name": "0988290475", + "new": true + }, + { + "number": "0367250217", + "type": "missed", + "id": 93, + "duration": 25, + "datetime": 1613662328, + "contact_id": 0, + "line_id": 0, + "name": "0367250217", + "new": true + }, + { + "number": "0184726018", + "type": "missed", + "id": 92, + "duration": 25, + "datetime": 1613225098, + "contact_id": 0, + "line_id": 0, + "name": "0184726018", + "new": true + } +] diff --git a/tests/components/freebox/fixtures/connection_get_status.json b/tests/components/freebox/fixtures/connection_get_status.json new file mode 100644 index 00000000000..362ac71edf0 --- /dev/null +++ b/tests/components/freebox/fixtures/connection_get_status.json @@ -0,0 +1,14 @@ +{ + "type": "ethernet", + "rate_down": 198900, + "bytes_up": 12035728872949, + "ipv4_port_range": [0, 65535], + "rate_up": 1440000, + "bandwidth_up": 700000000, + "ipv6": "2a01:e35:ffff:ffff::1", + "bandwidth_down": 1000000000, + "media": "ftth", + "state": "up", + "bytes_down": 2355966141297, + "ipv4": "82.67.00.00" +} diff --git a/tests/components/freebox/fixtures/home_alarm_get_values.json b/tests/components/freebox/fixtures/home_alarm_get_values.json new file mode 100644 index 00000000000..1e43a428296 --- /dev/null +++ b/tests/components/freebox/fixtures/home_alarm_get_values.json @@ -0,0 +1,5 @@ +{ + "refresh": 2000, + "value": "alarm2_armed", + "value_type": "string" +} diff --git a/tests/components/freebox/fixtures/home_get_nodes.json b/tests/components/freebox/fixtures/home_get_nodes.json new file mode 100644 index 00000000000..b72505279b2 --- /dev/null +++ b/tests/components/freebox/fixtures/home_get_nodes.json @@ -0,0 +1,2545 @@ +[ + { + "adapter": 2, + "area": 38, + "category": "camera", + "group": { + "label": "Salon" + }, + "id": 16, + "label": "Caméra II", + "name": "node_16", + "props": { + "Ip": "192.169.0.2", + "Login": "camfreebox", + "Mac": "34:2d:f2:e5:9d:ff", + "Pass": "xxxxx", + "Stream": "http://freeboxcam:mv...tream.m3u8" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection", + "name": "detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": { + "access": "rw", + "display": "disk" + }, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 2, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [] + }, + "value": 100, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [], + "status_text_range": [], + "unit": "dB" + }, + "value": -75, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png" + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal" + } + ], + "signal_links": [], + "slot_links": [{}], + "status": "active", + "type": { + "abstract": false, + "endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": { + "access": "rw", + "display": "disk" + }, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 2, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [] + }, + "value": 80, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [], + "status_text_range": [], + "unit": "dB" + }, + "value": -49, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png" + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal" + } + ], + "generic": false, + "icon": "/resources/images/ho...camera.png", + "inherit": "node::cam", + "label": "Caméra Freebox", + "name": "node::cam::freebox", + "params": {}, + "physical": true + } + }, + { + "adapter": 1, + "area": 38, + "category": "camera", + "group": { + "label": "Salon" + }, + "id": 15, + "label": "Caméra I", + "name": "node_15", + "props": { + "Ip": "192.169.0.2", + "Login": "camfreebox", + "Mac": "34:2d:f2:e5:9d:ff", + "Pass": "xxxxx", + "Stream": "http://freeboxcam:mv...tream.m3u8" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": { + "access": "rw", + "display": "disk" + }, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 2, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [] + }, + "value": 100, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [], + "status_text_range": [], + "unit": "dB" + }, + "value": -75, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png" + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal" + } + ], + "signal_links": [], + "slot_links": [{}], + "status": "active", + "type": { + "abstract": false, + "endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": { + "access": "rw", + "display": "disk" + }, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 2, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [] + }, + "value": 80, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [], + "status_text_range": [], + "unit": "dB" + }, + "value": -49, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png" + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal" + } + ], + "generic": false, + "icon": "/resources/images/ho...camera.png", + "inherit": "node::cam", + "label": "Caméra Freebox", + "name": "node::cam::freebox", + "params": {}, + "physical": true + } + }, + { + "adapter": 5, + "category": "kfb", + "group": { + "label": "" + }, + "id": 9, + "label": "Télécommande", + "name": "node_9", + "props": { + "Address": 5, + "Challenge": "65ae6b4def41f3e3a5a77ec63e988", + "FwVersion": 29798318, + "Gateway": 1, + "ItemId": "e76c2b75a4a6e2" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Activé", + "name": "enable", + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 1, + "label": "Activé", + "name": "enable", + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 2, + "label": "Bouton appuyé", + "name": "pushed", + "value": null, + "value_type": "int" + }, + { + "category": "", + "ep_type": "signal", + "id": 3, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "value": 100, + "value_type": "int" + } + ], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 10, + "name": "node_7", + "status": "active" + } + ], + "slot_links": [], + "status": "active", + "type": { + "abstract": false, + "endpoints": [], + "generic": false, + "icon": "/resources/images/home/pictos/telecommande.png", + "inherit": "node::domus", + "label": "Télécommande pour alarme", + "name": "node::domus::sercomm::keyfob", + "params": {}, + "physical": true + } + }, + { + "adapter": 5, + "area": 40, + "category": "dws", + "group": { + "label": "Entrée" + }, + "id": 11, + "label": "Ouverture porte", + "name": "node_11", + "props": { + "Address": 6, + "Challenge": "964a2dddf2c40c3e2384f66d2", + "FwVersion": 29798220, + "Gateway": 1, + "ItemId": "9eff759dd553de7" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/batt_%.png", + "range": [], + "status_text_range": [], + "unit": "%" + }, + "value": 100, + "value_type": "int", + "visibility": "normal" + } + ], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 12, + "name": "node_7", + "status": "active" + } + ], + "slot_links": [], + "status": "active", + "type": { + "abstract": false, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 3, + "label": "Alarme principale", + "name": "alarm1", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 4, + "label": "Alarme secondaire", + "name": "alarm2", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 5, + "label": "Zone temporisée", + "name": "timed", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "1battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 9, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 10, + "label": "Alarme", + "name": "alarm", + "param_type": "void", + "value_type": "void", + "visibility": "internal" + } + ], + "generic": false, + "icon": "/resources/images/home/pictos/detecteur_ouverture.png", + "inherit": "node::domus", + "label": "Détecteur d'ouverture de porte", + "name": "node::domus::sercomm::doorswitch", + "params": {}, + "physical": true + } + }, + { + "adapter": 5, + "area": 38, + "category": "pir", + "group": { + "label": "Salon" + }, + "id": 26, + "label": "Détecteur", + "name": "node_26", + "props": { + "Address": 9, + "Challenge": "ed2cc17f179862f5242256b3f597c367", + "FwVersion": 29871925, + "Gateway": 1, + "ItemId": "240d000f9fefe576" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/batt_x.png", + "status_text_range": [], + "unit": "%" + }, + "value": 100, + "value_type": "int", + "visibility": "normal" + } + ], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 12, + "name": "node_7", + "status": "active" + } + ], + "slot_links": [], + "status": "active", + "type": { + "abstract": false, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 3, + "label": "Alarme principale", + "name": "alarm1", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 4, + "label": "Alarme secondaire", + "name": "alarm2", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 5, + "label": "Zone temporisée", + "name": "timed", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 9, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 10, + "label": "Alarme", + "name": "alarm", + "param_type": "void", + "value_type": "void", + "visibility": "internal" + } + ], + "generic": false, + "icon": "/resources/images/home/pictos/detecteur_xxxx.png", + "inherit": "node::domus", + "label": "Détecteur infrarouge", + "name": "node::domus::sercomm::pir", + "params": {}, + "physical": true + } + }, + { + "adapter": 10, + "area": 38, + "category": "shutter", + "group": { + "label": "Salon" + }, + "id": 150, + "label": "Shutter 1", + "name": "node_150", + "type": { + "inherit": "node::trs" + } + }, + { + "adapter": 11, + "area": 38, + "category": "shutter", + "group": { + "label": "Salon" + }, + "id": 151, + "label": "Shutter 2", + "name": "node_151", + "type": { + "inherit": "node::ios" + } + }, + { + "adapter": 5, + "category": "alarm", + "group": { + "label": "" + }, + "id": 7, + "label": "Système d'alarme", + "name": "node_7", + "props": { + "Address": 3, + "Challenge": "447599f5cab8620122b913e55faf8e1d", + "FwVersion": 47396239, + "Gateway": 1, + "ItemId": "e515a55b04f32e6d" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "ui": {}, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "refresh": 2000, + "ui": {}, + "value": "0000", + "value_type": "string" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "refresh": 2000, + "ui": {}, + "value": 1, + "value_type": "int" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "refresh": 2000, + "ui": {}, + "value": 100, + "value_type": "int" + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "refresh": 2000, + "ui": {}, + "value": 15, + "value_type": "int" + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "refresh": 2000, + "ui": {}, + "value": 15, + "value_type": "int" + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "refresh": 2000, + "ui": {}, + "value": 300, + "value_type": "int" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": {}, + "value": 85, + "value_type": "int" + } + ], + "type": { + "abstract": false, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Trigger", + "name": "trigger", + "value_type": "void", + "visibility": "internal" + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "void", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 2, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "void", + "visibility": "internal" + }, + { + "ep_type": "slot", + "id": 3, + "label": "Passer le délai", + "name": "skip", + "value_type": "void", + "visibility": "internal" + }, + { + "ep_type": "slot", + "id": 4, + "label": "Désactiver l'alarme", + "name": "off", + "value_type": "void", + "visibility": "internal" + }, + { + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "value_type": "string", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 11, + "label": "État de l'alarme", + "name": "state", + "param_type": "void", + "value_type": "string", + "visibility": "internal" + }, + { + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "param_type": "void", + "value_type": "string", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 13, + "label": "Erreur", + "name": "error", + "param_type": "void", + "value_type": "string", + "visibility": "internal" + }, + { + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 20, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + } + ], + "generic": false, + "icon": "/resources/images/home/pictos/alarm_system.png", + "inherit": "node::domus", + "label": "Système d'alarme", + "name": "node::domus::freebox::secmod", + "params": {}, + "physical": true + } + } +] diff --git a/tests/components/freebox/fixtures/home_pir_get_values.json b/tests/components/freebox/fixtures/home_pir_get_values.json new file mode 100644 index 00000000000..a76fdd66286 --- /dev/null +++ b/tests/components/freebox/fixtures/home_pir_get_values.json @@ -0,0 +1,14 @@ +{ + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" +} diff --git a/tests/components/freebox/fixtures/lan_get_hosts_list.json b/tests/components/freebox/fixtures/lan_get_hosts_list.json new file mode 100644 index 00000000000..dccf6acee4a --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_hosts_list.json @@ -0,0 +1,274 @@ +[ + { + "l2ident": { + "id": "8C:97:EA:00:00:00", + "type": "mac_address" + }, + "active": true, + "persistent": false, + "names": [ + { + "name": "d633d0c8-958c-43cc-e807-d881b076924b", + "source": "mdns" + }, + { + "name": "Freebox Player POP", + "source": "mdns_srv" + } + ], + "vendor_name": "Freebox SAS", + "host_type": "smartphone", + "interface": "pub", + "id": "ether-8c:97:ea:00:00:00", + "last_time_reachable": 1614107652, + "primary_name_manual": false, + "l3connectivities": [ + { + "addr": "192.168.1.180", + "active": true, + "reachable": true, + "last_activity": 1614107614, + "af": "ipv4", + "last_time_reachable": 1614104242 + }, + { + "addr": "fe80::dcef:dbba:6604:31d1", + "active": true, + "reachable": true, + "last_activity": 1614107645, + "af": "ipv6", + "last_time_reachable": 1614107645 + }, + { + "addr": "2a01:e34:eda1:eb40:8102:4704:7ce0:2ace", + "active": false, + "reachable": false, + "last_activity": 1611574428, + "af": "ipv6", + "last_time_reachable": 1611574428 + }, + { + "addr": "2a01:e34:eda1:eb40:c8e5:c524:c96d:5f5e", + "active": false, + "reachable": false, + "last_activity": 1612475101, + "af": "ipv6", + "last_time_reachable": 1612475101 + }, + { + "addr": "2a01:e34:eda1:eb40:583a:49df:1df0:c2df", + "active": true, + "reachable": true, + "last_activity": 1614107652, + "af": "ipv6", + "last_time_reachable": 1614107652 + }, + { + "addr": "2a01:e34:eda1:eb40:147e:3569:86ab:6aaa", + "active": false, + "reachable": false, + "last_activity": 1612486752, + "af": "ipv6", + "last_time_reachable": 1612486752 + } + ], + "default_name": "Freebox Player POP", + "model": "fbx8am", + "reachable": true, + "last_activity": 1614107652, + "primary_name": "Freebox Player POP" + }, + { + "l2ident": { + "id": "DE:00:B0:00:00:00", + "type": "mac_address" + }, + "active": false, + "persistent": false, + "vendor_name": "", + "host_type": "workstation", + "interface": "pub", + "id": "ether-de:00:b0:00:00:00", + "last_time_reachable": 1607125599, + "primary_name_manual": false, + "default_name": "", + "l3connectivities": [ + { + "addr": "192.168.1.181", + "active": false, + "reachable": false, + "last_activity": 1607125599, + "af": "ipv4", + "last_time_reachable": 1607125599 + }, + { + "addr": "192.168.1.182", + "active": false, + "reachable": false, + "last_activity": 1605958758, + "af": "ipv4", + "last_time_reachable": 1605958758 + }, + { + "addr": "2a01:e34:eda1:eb40:dc00:b0ff:fedf:e30", + "active": false, + "reachable": false, + "last_activity": 1607125594, + "af": "ipv6", + "last_time_reachable": 1607125594 + } + ], + "reachable": false, + "last_activity": 1607125599, + "primary_name": "" + }, + { + "l2ident": { + "id": "DC:00:B0:00:00:00", + "type": "mac_address" + }, + "active": true, + "persistent": false, + "names": [ + { + "name": "Repeteur-Wifi-Freebox", + "source": "mdns" + }, + { + "name": "Repeteur Wifi Freebox", + "source": "mdns_srv" + } + ], + "vendor_name": "", + "host_type": "freebox_wifi", + "interface": "pub", + "id": "ether-dc:00:b0:00:00:00", + "last_time_reachable": 1614107678, + "primary_name_manual": false, + "l3connectivities": [ + { + "addr": "192.168.1.145", + "active": true, + "reachable": true, + "last_activity": 1614107678, + "af": "ipv4", + "last_time_reachable": 1614107678 + }, + { + "addr": "fe80::de00:b0ff:fe52:6ef6", + "active": true, + "reachable": true, + "last_activity": 1614107608, + "af": "ipv6", + "last_time_reachable": 1614107603 + }, + { + "addr": "2a01:e34:eda1:eb40:de00:b0ff:fe52:6ef6", + "active": true, + "reachable": true, + "last_activity": 1614107618, + "af": "ipv6", + "last_time_reachable": 1614107618 + } + ], + "default_name": "Repeteur Wifi Freebox", + "model": "fbxwmr", + "reachable": true, + "last_activity": 1614107678, + "primary_name": "Repeteur Wifi Freebox" + }, + { + "l2ident": { + "id": "5E:65:55:00:00:00", + "type": "mac_address" + }, + "active": false, + "persistent": false, + "names": [ + { + "name": "iPhoneofQuentin", + "source": "dhcp" + }, + { + "name": "iPhone-of-Quentin", + "source": "mdns" + } + ], + "vendor_name": "", + "host_type": "smartphone", + "interface": "pub", + "id": "ether-5e:65:55:00:00:00", + "last_time_reachable": 1612611982, + "primary_name_manual": false, + "default_name": "iPhonedeQuentin", + "l3connectivities": [ + { + "addr": "192.168.1.148", + "active": false, + "reachable": false, + "last_activity": 1612611973, + "af": "ipv4", + "last_time_reachable": 1612611973 + }, + { + "addr": "fe80::14ca:6c30:938b:e281", + "active": false, + "reachable": false, + "last_activity": 1609693223, + "af": "ipv6", + "last_time_reachable": 1609693223 + }, + { + "addr": "fe80::1c90:2b94:1ba2:bd8b", + "active": false, + "reachable": false, + "last_activity": 1610797303, + "af": "ipv6", + "last_time_reachable": 1610797303 + }, + { + "addr": "fe80::8c8:e58b:838e:6785", + "active": false, + "reachable": false, + "last_activity": 1612611951, + "af": "ipv6", + "last_time_reachable": 1612611946 + }, + { + "addr": "2a01:e34:eda1:eb40:f0e7:e198:3a69:58", + "active": false, + "reachable": false, + "last_activity": 1609693245, + "af": "ipv6", + "last_time_reachable": 1609693245 + }, + { + "addr": "2a01:e34:eda1:eb40:1dc4:c6f8:aa20:c83b", + "active": false, + "reachable": false, + "last_activity": 1610797176, + "af": "ipv6", + "last_time_reachable": 1610797176 + }, + { + "addr": "2a01:e34:eda1:eb40:6cf6:5811:1770:c662", + "active": false, + "reachable": false, + "last_activity": 1612611982, + "af": "ipv6", + "last_time_reachable": 1612611982 + }, + { + "addr": "2a01:e34:eda1:eb40:438:9b2c:4f8f:f48a", + "active": false, + "reachable": false, + "last_activity": 1612611946, + "af": "ipv6", + "last_time_reachable": 1612611946 + } + ], + "reachable": false, + "last_activity": 1612611982, + "primary_name": "iPhoneofQuentin" + } +] diff --git a/tests/components/freebox/fixtures/storage_get_disks.json b/tests/components/freebox/fixtures/storage_get_disks.json new file mode 100644 index 00000000000..befeb592faf --- /dev/null +++ b/tests/components/freebox/fixtures/storage_get_disks.json @@ -0,0 +1,109 @@ +[ + { + "idle_duration": 0, + "read_error_requests": 0, + "read_requests": 1815106, + "spinning": true, + "table_type": "raid", + "firmware": "0001", + "type": "sata", + "idle": true, + "connector": 2, + "id": 1000, + "write_error_requests": 0, + "time_before_spindown": 600, + "state": "disabled", + "write_requests": 80386151, + "total_bytes": 2000000000000, + "model": "ST2000LM015-2E8174", + "active_duration": 0, + "temp": 30, + "serial": "ZDZLBFHC", + "partitions": [ + { + "fstype": "raid", + "total_bytes": 0, + "label": "Volume 2000Go", + "id": 1000, + "internal": false, + "fsck_result": "no_run_yet", + "state": "umounted", + "disk_id": 1000, + "free_bytes": 0, + "used_bytes": 0, + "path": "L1ZvbHVtZSAyMDAwR28=" + } + ] + }, + { + "idle_duration": 0, + "read_error_requests": 0, + "read_requests": 3622038, + "spinning": true, + "table_type": "raid", + "firmware": "0001", + "type": "sata", + "idle": true, + "connector": 0, + "id": 2000, + "write_error_requests": 0, + "time_before_spindown": 600, + "state": "disabled", + "write_requests": 80386151, + "total_bytes": 2000000000000, + "model": "ST2000LM015-2E8174", + "active_duration": 0, + "temp": 31, + "serial": "ZDZLEJXE", + "partitions": [ + { + "fstype": "raid", + "total_bytes": 0, + "label": "Volume 2000Go 1", + "id": 2000, + "internal": false, + "fsck_result": "no_run_yet", + "state": "umounted", + "disk_id": 2000, + "free_bytes": 0, + "used_bytes": 0, + "path": "L1ZvbHVtZSAyMDAwR28gMQ==" + } + ] + }, + { + "idle_duration": 0, + "read_error_requests": 0, + "read_requests": 0, + "spinning": false, + "table_type": "superfloppy", + "firmware": "", + "type": "raid", + "idle": false, + "connector": 0, + "id": 3000, + "write_error_requests": 0, + "state": "enabled", + "write_requests": 0, + "total_bytes": 2000000000000, + "model": "", + "active_duration": 0, + "temp": 0, + "serial": "", + "partitions": [ + { + "fstype": "ext4", + "total_bytes": 1960000000000, + "label": "Freebox", + "id": 3000, + "internal": false, + "fsck_result": "no_run_yet", + "state": "mounted", + "disk_id": 3000, + "free_bytes": 1730000000000, + "used_bytes": 236910000000, + "path": "L0ZyZWVib3g=" + } + ] + } +] diff --git a/tests/components/freebox/fixtures/storage_get_raids.json b/tests/components/freebox/fixtures/storage_get_raids.json new file mode 100644 index 00000000000..eb4e3c36681 --- /dev/null +++ b/tests/components/freebox/fixtures/storage_get_raids.json @@ -0,0 +1,64 @@ +[ + { + "degraded": false, + "raid_disks": 2, + "next_check": 0, + "sync_action": "idle", + "level": "raid1", + "uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", + "sysfs_state": "clear", + "id": 0, + "sync_completed_pos": 0, + "members": [ + { + "total_bytes": 2000000000000, + "active_device": 1, + "id": 1000, + "corrected_read_errors": 0, + "array_id": 0, + "disk": { + "firmware": "0001", + "temp": 29, + "serial": "ZDZLBFHC", + "model": "ST2000LM015-2E8174" + }, + "role": "active", + "sct_erc_supported": false, + "sct_erc_enabled": false, + "dev_uuid": "fca8720e-13f9-11ee-9106-38d547790df8", + "device_location": "sata-internal-p2", + "set_name": "Freebox", + "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8" + }, + { + "total_bytes": 2000000000000, + "active_device": 0, + "id": 2000, + "corrected_read_errors": 0, + "array_id": 0, + "disk": { + "firmware": "0001", + "temp": 30, + "serial": "ZDZLEJXE", + "model": "ST2000LM015-2E8174" + }, + "role": "active", + "sct_erc_supported": false, + "sct_erc_enabled": false, + "dev_uuid": "16bf00d6-13fa-11ee-9106-38d547790df8", + "device_location": "sata-internal-p0", + "set_name": "Freebox", + "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8" + } + ], + "array_size": 2000000000000, + "state": "running", + "sync_speed": 0, + "name": "Freebox", + "check_interval": 0, + "disk_id": 3000, + "last_check": 1682884357, + "sync_completed_end": 0, + "sync_completed_percent": 0 + } +] diff --git a/tests/components/freebox/fixtures/system_get_config.json b/tests/components/freebox/fixtures/system_get_config.json new file mode 100644 index 00000000000..5dd72dcb4e3 --- /dev/null +++ b/tests/components/freebox/fixtures/system_get_config.json @@ -0,0 +1,57 @@ +{ + "mac": "68:A3:78:00:00:00", + "model_info": { + "has_ext_telephony": true, + "has_speakers_jack": true, + "wifi_type": "2d4_5g", + "pretty_name": "Freebox Server (r2)", + "customer_hdd_slots": 0, + "name": "fbxgw-r2/full", + "has_speakers": true, + "internal_hdd_size": 250, + "has_femtocell_exp": true, + "has_internal_hdd": true, + "has_dect": true + }, + "fans": [ + { + "id": "fan0_speed", + "name": "Ventilateur 1", + "value": 2130 + } + ], + "sensors": [ + { + "id": "temp_hdd", + "name": "Disque dur", + "value": 40 + }, + { + "id": "temp_hdd2", + "name": "Disque dur 2" + }, + { + "id": "temp_sw", + "name": "Température Switch", + "value": 50 + }, + { + "id": "temp_cpum", + "name": "Température CPU M", + "value": 60 + }, + { + "id": "temp_cpub", + "name": "Température CPU B", + "value": 56 + } + ], + "board_name": "fbxgw2r", + "disk_status": "active", + "uptime": "156 jours 19 heures 56 minutes 16 secondes", + "uptime_val": 13550176, + "user_main_storage": "Disque dur", + "box_authenticated": true, + "serial": "762601T190510709", + "firmware_version": "4.2.5" +} diff --git a/tests/components/freebox/fixtures/wifi_get_global_config.json b/tests/components/freebox/fixtures/wifi_get_global_config.json new file mode 100644 index 00000000000..189a039a69f --- /dev/null +++ b/tests/components/freebox/fixtures/wifi_get_global_config.json @@ -0,0 +1,4 @@ +{ + "enabled": true, + "mac_filter_state": "disabled" +} From d75a6a3b4b2b0ec34a04de866d007eede2adc9b1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Oct 2023 17:25:08 +0100 Subject: [PATCH 124/982] Use right functions for fixtures in Freebox test (#103135) --- tests/components/freebox/const.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index c2951edf8bb..a7dd3132719 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -1,43 +1,42 @@ """Test constants.""" -from homeassistant.components.freebox.const import DOMAIN -from tests.common import load_json_object_fixture +from tests.common import load_json_array_fixture, load_json_object_fixture MOCK_HOST = "myrouter.freeboxos.fr" MOCK_PORT = 1234 # router -DATA_SYSTEM_GET_CONFIG = load_json_object_fixture("system_get_config.json", DOMAIN) +DATA_SYSTEM_GET_CONFIG = load_json_object_fixture("freebox/system_get_config.json") # sensors DATA_CONNECTION_GET_STATUS = load_json_object_fixture( - "connection_get_status.json", DOMAIN + "freebox/connection_get_status.json" ) -DATA_CALL_GET_CALLS_LOG = load_json_object_fixture("call_get_calls_log.json", DOMAIN) +DATA_CALL_GET_CALLS_LOG = load_json_array_fixture("freebox/call_get_calls_log.json") -DATA_STORAGE_GET_DISKS = load_json_object_fixture("storage_get_disks.json", DOMAIN) +DATA_STORAGE_GET_DISKS = load_json_array_fixture("freebox/storage_get_disks.json") -DATA_STORAGE_GET_RAIDS = load_json_object_fixture("storage_get_raids.json", DOMAIN) +DATA_STORAGE_GET_RAIDS = load_json_array_fixture("freebox/storage_get_raids.json") # switch -WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture("wifi_get_global_config.json", DOMAIN) +WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture("freebox/wifi_get_global_config.json") # device_tracker -DATA_LAN_GET_HOSTS_LIST = load_json_object_fixture("lan_get_hosts_list.json", DOMAIN) +DATA_LAN_GET_HOSTS_LIST = load_json_array_fixture("freebox/lan_get_hosts_list.json") # Home # ALL -DATA_HOME_GET_NODES = load_json_object_fixture("home_get_nodes.json", DOMAIN) +DATA_HOME_GET_NODES = load_json_array_fixture("freebox/home_get_nodes.json") # Home # PIR node id 26, endpoint id 6 -DATA_HOME_PIR_GET_VALUES = load_json_object_fixture("home_pir_get_values.json", DOMAIN) +DATA_HOME_PIR_GET_VALUES = load_json_object_fixture("freebox/home_pir_get_values.json") # Home # ALARM node id 7, endpoint id 11 DATA_HOME_ALARM_GET_VALUES = load_json_object_fixture( - "home_alarm_get_values.json", DOMAIN + "freebox/home_alarm_get_values.json" ) From 9b27552238e6afa62db139da371108dde7bdb8ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 12:38:05 -0500 Subject: [PATCH 125/982] Fix race in starting reauth flows (#103130) --- homeassistant/config_entries.py | 31 +++++++++++++++++++++----- tests/components/smarttub/test_init.py | 1 + tests/test_config_entries.py | 14 ++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 02a9dd9dade..2b8f1ec4065 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -223,6 +223,7 @@ class ConfigEntry: "_async_cancel_retry_setup", "_on_unload", "reload_lock", + "_reauth_lock", "_tasks", "_background_tasks", "_integration_for_domain", @@ -321,6 +322,8 @@ class ConfigEntry: # Reload lock to prevent conflicting reloads self.reload_lock = asyncio.Lock() + # Reauth lock to prevent concurrent reauth flows + self._reauth_lock = asyncio.Lock() self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() @@ -727,12 +730,28 @@ class ConfigEntry: data: dict[str, Any] | None = None, ) -> None: """Start a reauth flow.""" + # We will check this again in the task when we hold the lock, + # but we also check it now to try to avoid creating the task. if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): # Reauth flow already in progress for this entry return - hass.async_create_task( - hass.config_entries.flow.async_init( + self._async_init_reauth(hass, context, data), + f"config entry reauth {self.title} {self.domain} {self.entry_id}", + ) + + async def _async_init_reauth( + self, + hass: HomeAssistant, + context: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + ) -> None: + """Start a reauth flow.""" + async with self._reauth_lock: + if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): + # Reauth flow already in progress for this entry + return + await hass.config_entries.flow.async_init( self.domain, context={ "source": SOURCE_REAUTH, @@ -742,9 +761,7 @@ class ConfigEntry: } | (context or {}), data=self.data | (data or {}), - ), - f"config entry reauth {self.title} {self.domain} {self.entry_id}", - ) + ) @callback def async_get_active_flows( @@ -754,7 +771,9 @@ class ConfigEntry: return ( flow for flow in hass.config_entries.flow.async_progress_by_handler( - self.domain, match_context={"entry_id": self.entry_id} + self.domain, + match_context={"entry_id": self.entry_id}, + include_uninitialized=True, ) if flow["context"].get("source") in sources ) diff --git a/tests/components/smarttub/test_init.py b/tests/components/smarttub/test_init.py index 0e88f3ed7c7..929ad687e11 100644 --- a/tests/components/smarttub/test_init.py +++ b/tests/components/smarttub/test_init.py @@ -42,6 +42,7 @@ async def test_setup_auth_failed( config_entry.add_to_hass(hass) with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_ERROR mock_flow_init.assert_called_with( DOMAIN, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d17c724cb2a..eb771b7e6a6 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3791,6 +3791,20 @@ async def test_reauth(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 2 + # Abort all existing flows + for flow in hass.config_entries.flow.async_progress(): + hass.config_entries.flow.async_abort(flow["flow_id"]) + await hass.async_block_till_done() + + # Check that we can't start duplicate reauth flows + # without blocking between flows + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + async def test_get_active_flows(hass: HomeAssistant) -> None: """Test the async_get_active_flows helper.""" From 3ebd029026cc36081ce1c885a5f2c144474bd198 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 12:56:15 -0500 Subject: [PATCH 126/982] Bump aiohomekit to 3.0.9 (#103123) --- homeassistant/components/homekit_controller/connection.py | 4 +++- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 1d0eb9cdd83..ef806cb52bc 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -884,7 +884,9 @@ class HKDevice: self._config_changed_callbacks.add(callback_) return partial(self._remove_config_changed_callback, callback_) - async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + async def get_characteristics( + self, *args: Any, **kwargs: Any + ) -> dict[tuple[int, int], dict[str, Any]]: """Read latest state from homekit accessory.""" return await self.pairing.get_characteristics(*args, **kwargs) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index ff918396640..91fd199e17c 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.8"], + "requirements": ["aiohomekit==3.0.9"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 280598927bc..f50a40fda41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -255,7 +255,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.8 +aiohomekit==3.0.9 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f54b057a4e..b4562501e1f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.8 +aiohomekit==3.0.9 # homeassistant.components.emulated_hue # homeassistant.components.http From 6433bf4d77171f471e7b1c7bdfa59d2f2e1e45f7 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 31 Oct 2023 18:08:47 +0000 Subject: [PATCH 127/982] Create update component for System Bridge (#102966) * Create update component for System Bridge * Add --- .coveragerc | 1 + .../components/system_bridge/__init__.py | 1 + .../components/system_bridge/update.py | 65 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 homeassistant/components/system_bridge/update.py diff --git a/.coveragerc b/.coveragerc index 5ef7ece3bd8..2c9759b3c76 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1294,6 +1294,7 @@ omit = homeassistant/components/system_bridge/media_player.py homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py + homeassistant/components/system_bridge/update.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/__init__.py homeassistant/components/tado/binary_sensor.py diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 90a6f0659ef..b096a788906 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -49,6 +49,7 @@ PLATFORMS = [ Platform.MEDIA_PLAYER, Platform.NOTIFY, Platform.SENSOR, + Platform.UPDATE, ] CONF_BRIDGE = "bridge" diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py new file mode 100644 index 00000000000..1d011b08f72 --- /dev/null +++ b/homeassistant/components/system_bridge/update.py @@ -0,0 +1,65 @@ +"""Support for System Bridge updates.""" +from __future__ import annotations + +from homeassistant.components.update import UpdateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SystemBridgeEntity +from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up System Bridge update based on a config entry.""" + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + SystemBridgeUpdateEntity( + coordinator, + entry.data[CONF_PORT], + ), + ] + ) + + +class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity): + """Defines a System Bridge update entity.""" + + _attr_has_entity_name = True + _attr_title = "System Bridge" + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + api_port: int, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + api_port, + "update", + ) + self._attr_name = coordinator.data.system.hostname + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + return self.coordinator.data.system.version + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self.coordinator.data.system.version_latest + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + return f"https://github.com/timmo001/system-bridge/releases/tag/{self.coordinator.data.system.version_latest}" From 4d475a9758fce481a069e84870751a57559ba8f6 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 31 Oct 2023 19:25:25 +0100 Subject: [PATCH 128/982] Don't try to load resources in safe mode (#103122) --- homeassistant/components/lovelace/websocket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index c9b7cb10386..b756c2765e1 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -62,6 +62,7 @@ async def websocket_lovelace_resources( if hass.config.safe_mode: connection.send_result(msg["id"], []) + return if not resources.loaded: await resources.async_load() From 8eb7766f309379d434d3c2da06b1b9f6d4b2d1c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 14:31:58 -0500 Subject: [PATCH 129/982] Avoid path construction for static files cache hit (#102882) --- homeassistant/components/http/static.py | 51 +++++++++++---------- tests/components/http/test_static.py | 61 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 24 deletions(-) create mode 100644 tests/components/http/test_static.py diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 6cb1bafdaca..5e2c4a7a7a9 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -22,10 +22,15 @@ CACHE_HEADERS: Final[Mapping[str, str]] = { PATH_CACHE = LRU(512) -def _get_file_path( - filename: str | Path, directory: Path, follow_symlinks: bool -) -> Path | None: - filepath = directory.joinpath(filename).resolve() +def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path | None: + """Return the path to file on disk or None.""" + filename = Path(rel_url) + if filename.anchor: + # rel_url is an absolute name like + # /static/\\machine_name\c$ or /static/D:\path + # where the static dir is totally different + raise HTTPForbidden + filepath: Path = directory.joinpath(filename).resolve() if not follow_symlinks: filepath.relative_to(directory) # on opening a dir, load its contents if allowed @@ -40,27 +45,24 @@ class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" async def _handle(self, request: Request) -> StreamResponse: + """Return requested file from disk as a FileResponse.""" rel_url = request.match_info["filename"] - hass: HomeAssistant = request.app[KEY_HASS] - filename = Path(rel_url) - if filename.anchor: - # rel_url is an absolute name like - # /static/\\machine_name\c$ or /static/D:\path - # where the static dir is totally different - raise HTTPForbidden() - try: - key = (filename, self._directory, self._follow_symlinks) - if (filepath := PATH_CACHE.get(key)) is None: - filepath = PATH_CACHE[key] = await hass.async_add_executor_job( - _get_file_path, filename, self._directory, self._follow_symlinks - ) - except (ValueError, FileNotFoundError) as error: - # relatively safe - raise HTTPNotFound() from error - except Exception as error: - # perm error or other kind! - request.app.logger.exception(error) - raise HTTPNotFound() from error + key = (rel_url, self._directory, self._follow_symlinks) + if (filepath := PATH_CACHE.get(key)) is None: + hass: HomeAssistant = request.app[KEY_HASS] + try: + filepath = await hass.async_add_executor_job(_get_file_path, *key) + except (ValueError, FileNotFoundError) as error: + # relatively safe + raise HTTPNotFound() from error + except HTTPForbidden: + # forbidden + raise + except Exception as error: + # perm error or other kind! + request.app.logger.exception(error) + raise HTTPNotFound() from error + PATH_CACHE[key] = filepath if filepath: return FileResponse( @@ -68,4 +70,5 @@ class CachingStaticResource(StaticResource): chunk_size=self._chunk_size, headers=CACHE_HEADERS, ) + return await super()._handle(request) diff --git a/tests/components/http/test_static.py b/tests/components/http/test_static.py new file mode 100644 index 00000000000..1d711464966 --- /dev/null +++ b/tests/components/http/test_static.py @@ -0,0 +1,61 @@ +"""The tests for http static files.""" + + +from pathlib import Path + +from aiohttp.test_utils import TestClient +from aiohttp.web_exceptions import HTTPForbidden +import pytest + +from homeassistant.components.http.static import CachingStaticResource, _get_file_path +from homeassistant.core import EVENT_HOMEASSISTANT_START, HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +async def http(hass: HomeAssistant) -> None: + """Ensure http is set up.""" + assert await async_setup_component(hass, "http", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + +@pytest.fixture +async def mock_http_client(hass: HomeAssistant, aiohttp_client: ClientSessionGenerator): + """Start the Home Assistant HTTP component.""" + return await aiohttp_client(hass.http.app, server_kwargs={"skip_url_asserts": True}) + + +@pytest.mark.parametrize( + ("url", "canonical_url"), + ( + ("//a", "//a"), + ("///a", "///a"), + ("/c:\\a\\b", "/c:%5Ca%5Cb"), + ), +) +async def test_static_path_blocks_anchors( + hass: HomeAssistant, + mock_http_client: TestClient, + tmp_path: Path, + url: str, + canonical_url: str, +) -> None: + """Test static paths block anchors.""" + app = hass.http.app + + resource = CachingStaticResource(url, str(tmp_path)) + assert resource.canonical == canonical_url + app.router.register_resource(resource) + app["allow_configured_cors"](resource) + + resp = await mock_http_client.get(canonical_url, allow_redirects=False) + assert resp.status == 403 + + # Tested directly since aiohttp will block it before + # it gets here but we want to make sure if aiohttp ever + # changes we still block it. + with pytest.raises(HTTPForbidden): + _get_file_path(canonical_url, tmp_path, False) From 1a6184a9aaae04bb28a53ac0fcdf1f75cf6ea9aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 16:29:22 -0500 Subject: [PATCH 130/982] Allow non-admins to subscribe to the issue registry updated event (#103145) --- homeassistant/auth/permissions/events.py | 2 ++ homeassistant/components/websocket_api/commands.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/auth/permissions/events.py b/homeassistant/auth/permissions/events.py index d50da96a39f..aec23331664 100644 --- a/homeassistant/auth/permissions/events.py +++ b/homeassistant/auth/permissions/events.py @@ -19,6 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. @@ -28,6 +29,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[str]] = { EVENT_CORE_CONFIG_UPDATE, EVENT_DEVICE_REGISTRY_UPDATED, EVENT_ENTITY_REGISTRY_UPDATED, + EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, EVENT_LOVELACE_UPDATED, EVENT_PANELS_UPDATED, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 7d59fd39a0c..369eca38925 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -57,6 +57,8 @@ from .messages import construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" +_LOGGER = logging.getLogger(__name__) + @callback def async_register_commands( @@ -134,7 +136,12 @@ def handle_subscribe_events( event_type = msg["event_type"] if event_type not in SUBSCRIBE_ALLOWLIST and not connection.user.is_admin: - raise Unauthorized + _LOGGER.error( + "Refusing to allow %s to subscribe to event %s", + connection.user.name, + event_type, + ) + raise Unauthorized(user_id=connection.user.id) if event_type == EVENT_STATE_CHANGED: forward_events = partial( From e880ad7bda012beba55064cf53f1c375ee1d0427 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Nov 2023 00:18:21 +0100 Subject: [PATCH 131/982] Improve reload of legacy groups (#102925) * Improve reload of legacy groups * Simplify reload * Get rid of inner function * Fix logic when there are no group.group entities * Update homeassistant/components/group/__init__.py Co-authored-by: J. Nick Koston * Fix type hints --------- Co-authored-by: J. Nick Koston --- homeassistant/components/group/__init__.py | 27 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 1092bc5834b..ae246041db9 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -293,14 +293,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await _async_process_config(hass, config) async def reload_service_handler(service: ServiceCall) -> None: - """Remove all user-defined groups and load new ones from config.""" - auto = [e for e in component.entities if e.created_by_service] + """Group reload handler. - if (conf := await component.async_prepare_reload()) is None: + - Remove group.group entities not created by service calls and set them up again + - Reload xxx.group platforms + """ + if (conf := await component.async_prepare_reload(skip_reset=True)) is None: return - await _async_process_config(hass, conf) - await component.async_add_entities(auto) + # Simplified + modified version of EntityPlatform.async_reset: + # - group.group never retries setup + # - group.group never polls + # - We don't need to reset EntityPlatform._setup_complete + # - Only remove entities which were not created by service calls + tasks = [ + entity.async_remove() + for entity in component.entities + if entity.entity_id.startswith("group.") and not entity.created_by_service + ] + + if tasks: + await asyncio.gather(*tasks) + + component.config = None + + await _async_process_config(hass, conf) await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) From f944c68e01e8b0310908cefd1620ca135d2e51e9 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 1 Nov 2023 01:54:51 +0100 Subject: [PATCH 132/982] Bump python-kasa to 0.5.4 for tplink (#103038) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/test_init.py | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index d13adb8ec47..e0ac41bdec6 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -169,5 +169,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.5.3"] + "requirements": ["python-kasa[speedups]==0.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index f50a40fda41..b556b922025 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2141,7 +2141,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.3 +python-kasa[speedups]==0.5.4 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4562501e1f..2a5943ed7b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.3 +python-kasa[speedups]==0.5.4 # homeassistant.components.matter python-matter-server==4.0.0 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 4206c0de6ad..c40560d2a89 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -29,7 +29,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_configuring_tplink_causes_discovery(hass: HomeAssistant) -> None: """Test that specifying empty config does discovery.""" - with patch("homeassistant.components.tplink.Discover.discover") as discover: + with patch("homeassistant.components.tplink.Discover.discover") as discover, patch( + "homeassistant.components.tplink.Discover.discover_single" + ): discover.return_value = {MagicMock(): MagicMock()} await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() From 04dfbd2e032c2edf4f1e277c2d24872b6b50dec7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 Oct 2023 19:48:33 -0700 Subject: [PATCH 133/982] Improve fitbit oauth token error handling in config flow (#103131) * Improve fitbit oauth token error handling in config flow * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Update tests with updated error reason --------- Co-authored-by: Martin Hjelmare --- .../fitbit/application_credentials.py | 13 +++-- .../components/fitbit/config_flow.py | 15 ++++++ homeassistant/components/fitbit/strings.json | 5 +- tests/components/fitbit/test_config_flow.py | 53 ++++++++++++++++++- 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index e66b9ca9014..caf0384eca2 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -59,13 +59,16 @@ class FitbitOAuth2Implementation(AuthImplementation): resp = await session.post(self.token_url, data=data, headers=self._headers) resp.raise_for_status() except aiohttp.ClientResponseError as err: - error_body = await resp.text() - _LOGGER.debug("Client response error body: %s", error_body) + if _LOGGER.isEnabledFor(logging.DEBUG): + error_body = await resp.text() if not session.closed else "" + _LOGGER.debug( + "Client response error status=%s, body=%s", err.status, error_body + ) if err.status == HTTPStatus.UNAUTHORIZED: - raise FitbitAuthException from err - raise FitbitApiException from err + raise FitbitAuthException(f"Unauthorized error: {err}") from err + raise FitbitApiException(f"Server error response: {err}") from err except aiohttp.ClientError as err: - raise FitbitApiException from err + raise FitbitApiException(f"Client connection error: {err}") from err return cast(dict, await resp.json()) @property diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index ee2340e7587..dd7e79e2c65 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -53,6 +53,21 @@ class OAuth2FlowHandler( return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + async def async_step_creation( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create config entry from external data with Fitbit specific error handling.""" + try: + return await super().async_step_creation() + except FitbitAuthException as err: + _LOGGER.error( + "Failed to authenticate when creating Fitbit credentials: %s", err + ) + return self.async_abort(reason="invalid_auth") + except FitbitApiException as err: + _LOGGER.error("Failed to create Fitbit credentials: %s", err) + return self.async_abort(reason="cannot_connect") + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 2d74408a73f..889b56f1bbd 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -16,9 +16,10 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", "wrong_account": "The user credentials provided do not match this Fitbit account." diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index cf2d5d17f22..d51379c9adc 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -88,6 +88,57 @@ async def test_full_flow( } +@pytest.mark.parametrize( + ("status_code", "error_reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_token_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, + status_code: HTTPStatus, + error_reason: str, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=status_code, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == error_reason + + @pytest.mark.parametrize( ("http_status", "json", "error_reason"), [ @@ -460,7 +511,7 @@ async def test_reauth_flow( "refresh_token": "updated-refresh-token", "access_token": "updated-access-token", "type": "Bearer", - "expires_in": 60, + "expires_in": "60", }, ) From 6ea5af7575083ed50fc825b231872db21d5ec4ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Nov 2023 02:56:48 -0500 Subject: [PATCH 134/982] Bump aioesphomeapi to 18.2.1 (#103156) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8968fa7da4f..4619ffef4c5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.2.0", + "aioesphomeapi==18.2.1", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index b556b922025..a082a34af6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.0 +aioesphomeapi==18.2.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a5943ed7b0..33c752de5b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.0 +aioesphomeapi==18.2.1 # homeassistant.components.flo aioflo==2021.11.0 From daee5baef6c245f34616feb4665082efc7677e60 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 Nov 2023 09:25:56 +0100 Subject: [PATCH 135/982] Fix mqtt is not reloading without yaml config (#103159) --- homeassistant/components/mqtt/__init__.py | 2 +- tests/components/mqtt/test_init.py | 36 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ac229cb677f..be283271dee 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -233,7 +233,7 @@ async def async_check_config_schema( ) -> None: """Validate manually configured MQTT items.""" mqtt_data = get_mqtt_data(hass) - mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml[DOMAIN] + mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml.get(DOMAIN, {}) for mqtt_config_item in mqtt_config: for domain, config_items in mqtt_config_item.items(): schema = mqtt_data.reload_schema[domain] diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b071252ea64..2aa8de388b1 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3974,3 +3974,39 @@ async def test_reload_with_invalid_config( # Test nothing changed as loading the config failed assert hass.states.get("sensor.test") is not None + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "sensor": { + "name": "test", + "state_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_with_empty_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reloading yaml config fails.""" + await mqtt_mock_entry() + assert hass.states.get("sensor.test") is not None + + # Reload with an empty config and assert again + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test") is None From 78e546b35a433b92ed6648a10d87508efb5ecab1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Nov 2023 04:25:02 -0500 Subject: [PATCH 136/982] Avoid enumerating the whole state machine on api service calls (#103147) --- homeassistant/components/api/__init__.py | 27 ++++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 0cade0f81ca..077e5ec9093 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,9 +1,11 @@ """Rest API for Home Assistant.""" import asyncio from asyncio import timeout +from collections.abc import Collection from functools import lru_cache from http import HTTPStatus import logging +from typing import Any from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest @@ -16,6 +18,7 @@ from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ( CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, + EVENT_STATE_CHANGED, MATCH_ALL, URL_API, URL_API_COMPONENTS, @@ -38,10 +41,12 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util.json import json_loads +from homeassistant.util.read_only_dict import ReadOnlyDict _LOGGER = logging.getLogger(__name__) @@ -369,6 +374,18 @@ class APIDomainServicesView(HomeAssistantView): ) context = self.context(request) + changed_states: list[ReadOnlyDict[str, Collection[Any]]] = [] + + @ha.callback + def _async_save_changed_entities( + event: EventType[EventStateChangedData], + ) -> None: + if event.context == context and (state := event.data["new_state"]): + changed_states.append(state.as_dict()) + + cancel_listen = hass.bus.async_listen( + EVENT_STATE_CHANGED, _async_save_changed_entities, run_immediately=True + ) try: await hass.services.async_call( @@ -376,12 +393,8 @@ class APIDomainServicesView(HomeAssistantView): ) except (vol.Invalid, ServiceNotFound) as ex: raise HTTPBadRequest() from ex - - changed_states = [] - - for state in hass.states.async_all(): - if state.context is context: - changed_states.append(state) + finally: + cancel_listen() return self.json(changed_states) From f624946ac03095042f5b399303b13a7ab4a1088b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Nov 2023 11:05:17 +0100 Subject: [PATCH 137/982] Update frontend to 20231030.1 (#103163) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b1eaaaf77e1..6fffc0e8acd 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231030.0"] + "requirements": ["home-assistant-frontend==20231030.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd1623c7d0d..a70bcf4524a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a082a34af6f..cf7039772a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33c752de5b8..fd62642e98f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 412b0e1c55d55fcd9979ee3f80cc36c9a955f942 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 Nov 2023 12:37:59 +0100 Subject: [PATCH 138/982] Bump python-holidays to 0.35 (#103092) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 698ef17902f..1c9a533d998 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.28"] + "requirements": ["holidays==0.35"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf7039772a5..c9a071bd0ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1004,7 +1004,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.28 +holidays==0.35 # homeassistant.components.frontend home-assistant-frontend==20231030.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd62642e98f..6d336545a8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -793,7 +793,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.28 +holidays==0.35 # homeassistant.components.frontend home-assistant-frontend==20231030.1 From 6e5479d307faa92b0b3c16489d9ce7452f062291 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 Nov 2023 13:25:33 +0100 Subject: [PATCH 139/982] Bump aiowaqi to 3.0.0 (#103166) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index 1cac5be375b..f5731da2a7e 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==2.1.0"] + "requirements": ["aiowaqi==3.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c9a071bd0ea..711d7b75c11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -378,7 +378,7 @@ aiovlc==0.1.0 aiovodafone==0.4.2 # homeassistant.components.waqi -aiowaqi==2.1.0 +aiowaqi==3.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d336545a8c..6ca70434dc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aiovlc==0.1.0 aiovodafone==0.4.2 # homeassistant.components.waqi -aiowaqi==2.1.0 +aiowaqi==3.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 From 8b7cfc070e7da6dd4f9a64a79c280ee78413f573 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 1 Nov 2023 14:21:39 +0100 Subject: [PATCH 140/982] Move base entity of system_bridge to own module (#103167) * Move base entity of system_bridge to own module * Add entity.py to .coveragerc --- .coveragerc | 1 + .../components/system_bridge/__init__.py | 42 ----------------- .../components/system_bridge/binary_sensor.py | 2 +- .../components/system_bridge/entity.py | 47 +++++++++++++++++++ .../components/system_bridge/media_player.py | 2 +- .../components/system_bridge/sensor.py | 2 +- .../components/system_bridge/update.py | 2 +- 7 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/system_bridge/entity.py diff --git a/.coveragerc b/.coveragerc index 2c9759b3c76..e41d668ed56 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1291,6 +1291,7 @@ omit = homeassistant/components/system_bridge/__init__.py homeassistant/components/system_bridge/binary_sensor.py homeassistant/components/system_bridge/coordinator.py + homeassistant/components/system_bridge/entity.py homeassistant/components/system_bridge/media_player.py homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index b096a788906..843640695e4 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -36,8 +36,6 @@ from homeassistant.helpers import ( discovery, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULES from .coordinator import SystemBridgeDataUpdateCoordinator @@ -330,43 +328,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload the config entry when it changed.""" await hass.config_entries.async_reload(entry.entry_id) - - -class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): - """Defines a base System Bridge entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: SystemBridgeDataUpdateCoordinator, - api_port: int, - key: str, - ) -> None: - """Initialize the System Bridge entity.""" - super().__init__(coordinator) - - self._hostname = coordinator.data.system.hostname - self._key = f"{self._hostname}_{key}" - self._configuration_url = ( - f"http://{self._hostname}:{api_port}/app/settings.html" - ) - self._mac_address = coordinator.data.system.mac_address - self._uuid = coordinator.data.system.uuid - self._version = coordinator.data.system.version - - @property - def unique_id(self) -> str: - """Return the unique ID for this entity.""" - return self._key - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this System Bridge instance.""" - return DeviceInfo( - configuration_url=self._configuration_url, - connections={(dr.CONNECTION_NETWORK_MAC, self._mac_address)}, - identifiers={(DOMAIN, self._uuid)}, - name=self._hostname, - sw_version=self._version, - ) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index e3ecc3817a6..511feeaf93c 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SystemBridgeEntity from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity @dataclass diff --git a/homeassistant/components/system_bridge/entity.py b/homeassistant/components/system_bridge/entity.py new file mode 100644 index 00000000000..72a6fc93977 --- /dev/null +++ b/homeassistant/components/system_bridge/entity.py @@ -0,0 +1,47 @@ +"""Base entity for the system bridge integration.""" +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator + + +class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): + """Defines a base System Bridge entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + api_port: int, + key: str, + ) -> None: + """Initialize the System Bridge entity.""" + super().__init__(coordinator) + + self._hostname = coordinator.data.system.hostname + self._key = f"{self._hostname}_{key}" + self._configuration_url = ( + f"http://{self._hostname}:{api_port}/app/settings.html" + ) + self._mac_address = coordinator.data.system.mac_address + self._uuid = coordinator.data.system.uuid + self._version = coordinator.data.system.version + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return self._key + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this System Bridge instance.""" + return DeviceInfo( + configuration_url=self._configuration_url, + connections={(dr.CONNECTION_NETWORK_MAC, self._mac_address)}, + identifiers={(DOMAIN, self._uuid)}, + name=self._hostname, + sw_version=self._version, + ) diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index 088c57573f1..fea0837497d 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -22,9 +22,9 @@ from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SystemBridgeEntity from .const import DOMAIN from .coordinator import SystemBridgeCoordinatorData, SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity STATUS_CHANGING: Final[str] = "CHANGING" STATUS_STOPPED: Final[str] = "STOPPED" diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 151a6882e26..9c12e14e264 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -28,9 +28,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util.dt import utcnow -from . import SystemBridgeEntity from .const import DOMAIN from .coordinator import SystemBridgeCoordinatorData, SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity ATTR_AVAILABLE: Final = "available" ATTR_FILESYSTEM: Final = "filesystem" diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py index 1d011b08f72..5f667fad30d 100644 --- a/homeassistant/components/system_bridge/update.py +++ b/homeassistant/components/system_bridge/update.py @@ -7,9 +7,9 @@ from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SystemBridgeEntity from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity async def async_setup_entry( From cef68ea33c509369a71bd1f5a775b17dc25b4f63 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Wed, 1 Nov 2023 15:21:42 +0100 Subject: [PATCH 141/982] Add hardware version to Roomba (#103171) --- homeassistant/components/roomba/irobot_base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index ffa4e2d8292..71651fa20b1 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -71,6 +71,7 @@ class IRobotEntity(Entity): self.vacuum_state = roomba_reported_state(roomba) self._name = self.vacuum_state.get("name") self._version = self.vacuum_state.get("softwareVer") + self._hw_version = self.vacuum_state.get("hardwareRev") self._sku = self.vacuum_state.get("sku") @property @@ -99,6 +100,7 @@ class IRobotEntity(Entity): model=self._sku, name=str(self._name), sw_version=self._version, + hw_version=self._hw_version, ) @property From 4a93465e8535f4c23b4d64b2b43a3894cdd5f5de Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 1 Nov 2023 10:41:41 -0400 Subject: [PATCH 142/982] Catch unexpected response in Honeywell (#103169) catch unexpected response --- homeassistant/components/honeywell/climate.py | 10 +++++++ tests/components/honeywell/test_climate.py | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index ab23c878c15..e9af4b2fd95 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -353,6 +353,11 @@ class HoneywellUSThermostat(ClimateEntity): if mode == "heat": await self._device.set_setpoint_heat(temperature) + except UnexpectedResponse as err: + raise HomeAssistantError( + "Honeywell set temperature failed: Invalid Response" + ) from err + except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) raise ValueError( @@ -369,6 +374,11 @@ class HoneywellUSThermostat(ClimateEntity): if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): await self._device.set_setpoint_heat(temperature) + except UnexpectedResponse as err: + raise HomeAssistantError( + "Honeywell set temperature failed: Invalid Response" + ) from err + except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) raise ValueError( diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 53cb70475c9..45ce862dba8 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -358,7 +358,24 @@ async def test_service_calls_off_mode( device.set_setpoint_heat.assert_called_with(77) assert "Invalid temperature" in caplog.text + device.set_setpoint_heat.reset_mock() + device.set_setpoint_heat.side_effect = aiosomecomfort.UnexpectedResponse caplog.clear() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_with(95) + device.set_setpoint_heat.assert_called_with(77) + reset_mock(device) await hass.services.async_call( CLIMATE_DOMAIN, @@ -702,6 +719,17 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() assert "Invalid temperature" in caplog.text + device.set_hold_heat.side_effect = aiosomecomfort.UnexpectedResponse + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + device.set_hold_heat.assert_called_once_with(datetime.time(2, 30), 59) + device.set_hold_heat.reset_mock() + caplog.clear() await hass.services.async_call( CLIMATE_DOMAIN, From 66dd3b153d78afeaa76e473434304dc3822b3858 Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Wed, 1 Nov 2023 07:46:13 -0700 Subject: [PATCH 143/982] Support HassTurnOn/Off intents for lock domain (#93231) * Support HassTurnOn/Off intents for lock domain Fix https://github.com/home-assistant/intents/issues/1347 * Added tests * Linting changes * Linting --- homeassistant/components/intent/__init__.py | 23 ++++++++++++ tests/components/intent/test_init.py | 39 +++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 5d3a2259bed..4d256795f55 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -10,6 +10,11 @@ from homeassistant.components.cover import ( SERVICE_OPEN_COVER, ) from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, @@ -89,6 +94,24 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): ) return + if state.domain == LOCK_DOMAIN: + # on = lock + # off = unlock + await self._run_then_background( + hass.async_create_task( + hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK + if self.service == SERVICE_TURN_ON + else SERVICE_UNLOCK, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + blocking=True, + ) + ) + ) + return + if not hass.services.has_service(state.domain, self.service): raise intent.IntentHandleError( f"Service {self.service} does not support entity {state.entity_id}" diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index fa8eb9cad61..6e4e00202c8 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -2,6 +2,7 @@ import pytest from homeassistant.components.cover import SERVICE_OPEN_COVER +from homeassistant.components.lock import SERVICE_LOCK from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -118,6 +119,44 @@ async def test_turn_on_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": ["light.test_light"]} +async def test_translated_turn_on_intent(hass: HomeAssistant) -> None: + """Test HassTurnOn intent on domains which don't have the intent.""" + result = await async_setup_component(hass, "homeassistant", {}) + result = await async_setup_component(hass, "intent", {}) + await hass.async_block_till_done() + assert result + + entity_registry = er.async_get(hass) + + cover = entity_registry.async_get_or_create("cover", "test", "cover_uid") + lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") + + hass.states.async_set(cover.entity_id, "closed") + hass.states.async_set(lock.entity_id, "unlocked") + cover_service_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) + lock_service_calls = async_mock_service(hass, "lock", SERVICE_LOCK) + + await intent.async_handle( + hass, "test", "HassTurnOn", {"name": {"value": cover.entity_id}} + ) + await intent.async_handle( + hass, "test", "HassTurnOn", {"name": {"value": lock.entity_id}} + ) + await hass.async_block_till_done() + + assert len(cover_service_calls) == 1 + call = cover_service_calls[0] + assert call.domain == "cover" + assert call.service == "open_cover" + assert call.data == {"entity_id": cover.entity_id} + + assert len(lock_service_calls) == 1 + call = lock_service_calls[0] + assert call.domain == "lock" + assert call.service == "lock" + assert call.data == {"entity_id": lock.entity_id} + + async def test_turn_off_intent(hass: HomeAssistant) -> None: """Test HassTurnOff intent.""" result = await async_setup_component(hass, "homeassistant", {}) From ae02e3f9030cf633568cab474b000a16698d6e1e Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Wed, 1 Nov 2023 16:27:23 +0100 Subject: [PATCH 144/982] Add reset filter counter button to Renson integration (#103126) Add reset filter counter button --- homeassistant/components/renson/button.py | 6 ++++++ homeassistant/components/renson/strings.json | 3 +++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index 53d995ba792..a91a057e0e7 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -48,6 +48,12 @@ ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, action_fn=lambda api: api.restart_device(), ), + RensonButtonEntityDescription( + key="reset_filter", + translation_key="reset_filter", + entity_category=EntityCategory.CONFIG, + action_fn=lambda api: api.reset_filter(), + ), ) diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 7099cdf2c45..d6d03ed1c44 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -16,6 +16,9 @@ "button": { "sync_time": { "name": "Sync time with device" + }, + "reset_filter": { + "name": "Reset filter counter" } }, "number": { From e48cb909f47f040342c4507437667a3053e6d39a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 1 Nov 2023 16:27:51 +0100 Subject: [PATCH 145/982] Use shorthand device info attribute for roomba (#103176) --- .../components/roomba/irobot_base.py | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 71651fa20b1..62afdf0df6b 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -69,10 +69,23 @@ class IRobotEntity(Entity): self.vacuum = roomba self._blid = blid self.vacuum_state = roomba_reported_state(roomba) - self._name = self.vacuum_state.get("name") - self._version = self.vacuum_state.get("softwareVer") - self._hw_version = self.vacuum_state.get("hardwareRev") - self._sku = self.vacuum_state.get("sku") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.robot_unique_id)}, + serial_number=self.vacuum_state.get("hwPartsRev", {}).get("navSerialNo"), + manufacturer="iRobot", + model=self.vacuum_state.get("sku"), + name=str(self.vacuum_state.get("name")), + sw_version=self.vacuum_state.get("softwareVer"), + hw_version=self.vacuum_state.get("hardwareRev"), + ) + + if mac_address := self.vacuum_state.get("hwPartsRev", {}).get( + "wlan0HwAddr", self.vacuum_state.get("mac") + ): + self._attr_device_info["connections"] = { + (dr.CONNECTION_NETWORK_MAC, mac_address) + } @property def robot_unique_id(self): @@ -84,25 +97,6 @@ class IRobotEntity(Entity): """Return the uniqueid of the vacuum cleaner.""" return self.robot_unique_id - @property - def device_info(self): - """Return the device info of the vacuum cleaner.""" - connections = None - if mac_address := self.vacuum_state.get("hwPartsRev", {}).get( - "wlan0HwAddr", self.vacuum_state.get("mac") - ): - connections = {(dr.CONNECTION_NETWORK_MAC, mac_address)} - return DeviceInfo( - connections=connections, - identifiers={(DOMAIN, self.robot_unique_id)}, - serial_number=self.vacuum_state.get("hwPartsRev", {}).get("navSerialNo"), - manufacturer="iRobot", - model=self._sku, - name=str(self._name), - sw_version=self._version, - hw_version=self._hw_version, - ) - @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" From ebee51a794ac1309213fe3309617dea98b8965cb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 1 Nov 2023 16:28:03 +0100 Subject: [PATCH 146/982] Add MAC address to roborock device info (#103175) --- homeassistant/components/roborock/coordinator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 0a9f42887a6..da15a80ce1f 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -11,6 +11,7 @@ from roborock.local_api import RoborockLocalClient from roborock.roborock_typing import DeviceProp from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -52,6 +53,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): sw_version=self.roborock_device_info.device.fv, ) + if mac := self.roborock_device_info.network_info.mac: + self.device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, mac)} + async def verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" try: From 12c5aec5e68ccbb6666e586e4bd37de50c939664 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Nov 2023 10:47:56 -0500 Subject: [PATCH 147/982] Add bluetooth address to august (#103177) --- homeassistant/components/august/entity.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 47f3b8be74f..d149e035ac4 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -2,10 +2,12 @@ from abc import abstractmethod from yalexs.doorbell import Doorbell -from yalexs.lock import Lock +from yalexs.lock import Lock, LockDetail from yalexs.util import get_configuration_url +from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -26,15 +28,18 @@ class AugustEntityMixin(Entity): super().__init__() self._data = data self._device = device + detail = self._detail self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer=MANUFACTURER, - model=self._detail.model, + model=detail.model, name=device.device_name, - sw_version=self._detail.firmware_version, + sw_version=detail.firmware_version, suggested_area=_remove_device_types(device.device_name, DEVICE_TYPES), configuration_url=get_configuration_url(data.brand), ) + if isinstance(detail, LockDetail) and (mac := detail.mac_address): + self._attr_device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_BLUETOOTH, mac)} @property def _device_id(self): From 56b4369f448b3ca1cdad54efd159cf83932e09ef Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 1 Nov 2023 17:53:40 +0100 Subject: [PATCH 148/982] Add MAC address to bsblan device info (#103180) --- homeassistant/components/bsblan/entity.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index d45749a9a86..3c7f41ce34d 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -5,7 +5,11 @@ from bsblan import BSBLAN, Device, Info, StaticState from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST -from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -26,6 +30,7 @@ class BSBLANEntity(Entity): self.client = client self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))}, identifiers={(DOMAIN, format_mac(device.MAC))}, manufacturer="BSBLAN Inc.", model=info.device_identification.value, From 67fa304b7869d787239e76aede6a3ec61dd41d16 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 1 Nov 2023 18:09:49 +0100 Subject: [PATCH 149/982] Use constant instead of plain key name for device info connections in roborock and roomba (#103182) --- homeassistant/components/roborock/coordinator.py | 3 ++- homeassistant/components/roomba/irobot_base.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index da15a80ce1f..dd4ef9e052f 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,6 +10,7 @@ from roborock.exceptions import RoborockException from roborock.local_api import RoborockLocalClient from roborock.roborock_typing import DeviceProp +from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -54,7 +55,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) if mac := self.roborock_device_info.network_info.mac: - self.device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, mac)} + self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} async def verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 62afdf0df6b..e4aa40e34f0 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -13,7 +13,7 @@ from homeassistant.components.vacuum import ( StateVacuumEntity, VacuumEntityFeature, ) -from homeassistant.const import STATE_IDLE, STATE_PAUSED +from homeassistant.const import ATTR_CONNECTIONS, STATE_IDLE, STATE_PAUSED import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -83,7 +83,7 @@ class IRobotEntity(Entity): if mac_address := self.vacuum_state.get("hwPartsRev", {}).get( "wlan0HwAddr", self.vacuum_state.get("mac") ): - self._attr_device_info["connections"] = { + self._attr_device_info[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, mac_address) } From 135944b6f0f25ffd245c7c09313696eabf7269a9 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Wed, 1 Nov 2023 20:15:18 +0100 Subject: [PATCH 150/982] Fix roomba translation key mismatch (#103191) --- homeassistant/components/roomba/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 3b2b34af67b..7d103111301 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -106,7 +106,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [ ), RoombaSensorEntityDescription( key="scrubs_count", - translation_key="scrubs", + translation_key="scrubs_count", icon="mdi:counter", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="Scrubs", From f05d2eb26161f0e6ad8a01bd04b1267a0c04583f Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Wed, 1 Nov 2023 21:12:57 +0100 Subject: [PATCH 151/982] Fix roomba error if battery stats are not available (#103196) --- homeassistant/components/roomba/irobot_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index e4aa40e34f0..b5dd9fedbd3 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -115,7 +115,7 @@ class IRobotEntity(Entity): @property def battery_stats(self): """Return the battery stats.""" - return self.vacuum_state.get("bbchg3") + return self.vacuum_state.get("bbchg3", {}) @property def _robot_state(self): From 47d6d6c344b21d2afa547291c413405fbf87410e Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 1 Nov 2023 16:34:04 -0400 Subject: [PATCH 152/982] Add button platform to Roborock (#103010) * add button platform to roborock * Update tests/components/roborock/test_button.py Co-authored-by: Duco Sebel <74970928+DCSBL@users.noreply.github.com> * Remove device class * improve tests * sort platforms --------- Co-authored-by: Duco Sebel <74970928+DCSBL@users.noreply.github.com> --- homeassistant/components/roborock/button.py | 112 ++++++++++++++++++ homeassistant/components/roborock/const.py | 7 +- .../components/roborock/strings.json | 14 +++ tests/components/roborock/test_button.py | 42 +++++++ 4 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/roborock/button.py create mode 100644 tests/components/roborock/test_button.py diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py new file mode 100644 index 00000000000..aba86ccb6b6 --- /dev/null +++ b/homeassistant/components/roborock/button.py @@ -0,0 +1,112 @@ +"""Support for Roborock button.""" +from __future__ import annotations + +from dataclasses import dataclass + +from roborock.roborock_typing import RoborockCommand + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockEntity + + +@dataclass +class RoborockButtonDescriptionMixin: + """Define an entity description mixin for button entities.""" + + command: RoborockCommand + param: list | dict | None + + +@dataclass +class RoborockButtonDescription( + ButtonEntityDescription, RoborockButtonDescriptionMixin +): + """Describes a Roborock button entity.""" + + +CONSUMABLE_BUTTON_DESCRIPTIONS = [ + RoborockButtonDescription( + key="reset_sensor_consumable", + icon="mdi:eye-outline", + translation_key="reset_sensor_consumable", + command=RoborockCommand.RESET_CONSUMABLE, + param=["sensor_dirty_time"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + RoborockButtonDescription( + key="reset_air_filter_consumable", + icon="mdi:air-filter", + translation_key="reset_air_filter_consumable", + command=RoborockCommand.RESET_CONSUMABLE, + param=["filter_work_time"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + RoborockButtonDescription( + key="reset_side_brush_consumable", + icon="mdi:brush", + translation_key="reset_side_brush_consumable", + command=RoborockCommand.RESET_CONSUMABLE, + param=["side_brush_work_time"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + RoborockButtonDescription( + key="reset_main_brush_consumable", + icon="mdi:brush", + translation_key="reset_main_brush_consumable", + command=RoborockCommand.RESET_CONSUMABLE, + param=["main_brush_work_time"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock button platform.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockButtonEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in CONSUMABLE_BUTTON_DESCRIPTIONS + ) + + +class RoborockButtonEntity(RoborockEntity, ButtonEntity): + """A class to define Roborock button entities.""" + + entity_description: RoborockButtonDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockButtonDescription, + ) -> None: + """Create a button entity.""" + super().__init__(unique_id, coordinator.device_info, coordinator.api) + self.entity_description = entity_description + + async def async_press(self) -> None: + """Press the button.""" + await self.send(self.entity_description.command, self.entity_description.param) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 36078e53b3e..d135f323e90 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -7,11 +7,12 @@ CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" PLATFORMS = [ - Platform.VACUUM, + Platform.BUTTON, + Platform.BINARY_SENSOR, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, - Platform.NUMBER, - Platform.BINARY_SENSOR, + Platform.VACUUM, ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 06cffcc2291..8841741d4a1 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -44,6 +44,20 @@ "name": "Water shortage" } }, + "button": { + "reset_sensor_consumable": { + "name": "Reset sensor consumable" + }, + "reset_air_filter_consumable": { + "name": "Reset air filter consumable" + }, + "reset_side_brush_consumable": { + "name": "Reset side brush consumable" + }, + "reset_main_brush_consumable": { + "name": "Reset main brush consumable" + } + }, "number": { "volume": { "name": "Volume" diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py new file mode 100644 index 00000000000..3948e0c161a --- /dev/null +++ b/tests/components/roborock/test_button.py @@ -0,0 +1,42 @@ +"""Test Roborock Button platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("button.roborock_s7_maxv_reset_sensor_consumable"), + ("button.roborock_s7_maxv_reset_air_filter_consumable"), + ("button.roborock_s7_maxv_reset_side_brush_consumable"), + "button.roborock_s7_maxv_reset_main_brush_consumable", + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test pressing the button entities.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id).state == "unknown" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" From 76115ce766654311311b6ec557d01298457c5358 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 2 Nov 2023 09:13:04 +0100 Subject: [PATCH 153/982] Fix Fronius entity initialisation (#103211) * Use None instead of raising ValueError if value invalid * use async_dispatcher_send --- homeassistant/components/fronius/__init__.py | 4 ++-- homeassistant/components/fronius/sensor.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 793f381d52f..c05f18107a0 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from .const import ( @@ -204,7 +204,7 @@ class FroniusSolarNet: # Only for re-scans. Initial setup adds entities through sensor.async_setup_entry if self.config_entry.state == ConfigEntryState.LOADED: - dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator) + async_dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator) _LOGGER.debug( "New inverter added (UID: %s)", diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index dfc76ae1415..f11855ce7e2 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -661,7 +661,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn if new_value is None: return self.entity_description.default_value if self.entity_description.invalid_when_falsy and not new_value: - raise ValueError(f"Ignoring zero value for {self.entity_id}.") + return None if isinstance(new_value, float): return round(new_value, 4) return new_value @@ -671,10 +671,9 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn """Handle updated data from the coordinator.""" try: self._attr_native_value = self._get_entity_value() - except (KeyError, ValueError): + except KeyError: # sets state to `None` if no default_value is defined in entity description # KeyError: raised when omitted in response - eg. at night when no production - # ValueError: raised when invalid zero value received self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() From fe482af561d9996e7b987eae7139e6e22fc09a9e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 2 Nov 2023 02:22:27 -0700 Subject: [PATCH 154/982] Add modernized fitbit battery level sensor (#102500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add modernized fitbit battery level sensor * Use entity names for existing battery sensors * Use icon from device class * Update homeassistant/components/fitbit/strings.json Co-authored-by: Joost Lekkerkerker * Update tests with lower case naming * Swap the names of the device battery sensors * Revert "Swap the names of the device battery sensors" This reverts commit c9516f6d06d77a27c5d7528cdbaa69153546bbbb. * Update homeassistant/components/fitbit/sensor.py Co-authored-by: Jan Vaníček * Improve typing --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Jan Vaníček Co-authored-by: Martin Hjelmare --- homeassistant/components/fitbit/sensor.py | 78 ++++++++++++++++++-- homeassistant/components/fitbit/strings.json | 10 +++ tests/components/fitbit/test_sensor.py | 40 +++++++++- 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d0d939ce67e..336a6620035 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -39,6 +39,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -504,10 +505,20 @@ SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", - name="Battery", + translation_key="battery", icon="mdi:battery", scope=FitbitScope.DEVICE, entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, +) +FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription( + key="devices/battery_level", + translation_key="battery_level", + scope=FitbitScope.DEVICE, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, ) FITBIT_RESOURCES_KEYS: Final[list[str]] = [ @@ -678,7 +689,7 @@ async def async_setup_entry( async_add_entities(entities) if data.device_coordinator and is_allowed_resource(FITBIT_RESOURCE_BATTERY): - async_add_entities( + battery_entities: list[SensorEntity] = [ FitbitBatterySensor( data.device_coordinator, user_profile.encoded_id, @@ -687,7 +698,17 @@ async def async_setup_entry( enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), ) for device in data.device_coordinator.data.values() + ] + battery_entities.extend( + FitbitBatteryLevelSensor( + data.device_coordinator, + user_profile.encoded_id, + FITBIT_RESOURCE_BATTERY_LEVEL, + device=device, + ) + for device in data.device_coordinator.data.values() ) + async_add_entities(battery_entities) class FitbitSensor(SensorEntity): @@ -742,8 +763,8 @@ class FitbitSensor(SensorEntity): self.async_schedule_update_ha_state(force_refresh=True) -class FitbitBatterySensor(CoordinatorEntity, SensorEntity): - """Implementation of a Fitbit sensor.""" +class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEntity): + """Implementation of a Fitbit battery sensor.""" entity_description: FitbitSensorEntityDescription _attr_attribution = ATTRIBUTION @@ -760,10 +781,12 @@ class FitbitBatterySensor(CoordinatorEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self.device = device - self._attr_unique_id = f"{user_profile_id}_{description.key}" - if device is not None: - self._attr_name = f"{device.device_version} Battery" - self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" + self._attr_unique_id = f"{user_profile_id}_{description.key}_{device.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{user_profile_id}_{device.id}")}, + name=device.device_version, + model=device.device_version, + ) if enable_default_override: self._attr_entity_registry_enabled_default = True @@ -794,3 +817,42 @@ class FitbitBatterySensor(CoordinatorEntity, SensorEntity): self.device = self.coordinator.data[self.device.id] self._attr_native_value = self.device.battery self.async_write_ha_state() + + +class FitbitBatteryLevelSensor( + CoordinatorEntity[FitbitDeviceCoordinator], SensorEntity +): + """Implementation of a Fitbit battery level sensor.""" + + entity_description: FitbitSensorEntityDescription + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: FitbitDeviceCoordinator, + user_profile_id: str, + description: FitbitSensorEntityDescription, + device: FitbitDevice, + ) -> None: + """Initialize the Fitbit sensor.""" + super().__init__(coordinator) + self.entity_description = description + self.device = device + self._attr_unique_id = f"{user_profile_id}_{description.key}_{device.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{user_profile_id}_{device.id}")}, + name=device.device_version, + model=device.device_version, + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.device = self.coordinator.data[self.device.id] + self._attr_native_value = self.device.battery_level + self.async_write_ha_state() diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 889b56f1bbd..7e85e232099 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -28,6 +28,16 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "entity": { + "sensor": { + "battery": { + "name": "Battery" + }, + "battery_level": { + "name": "Battery level" + } + } + }, "issues": { "deprecated_yaml_no_import": { "title": "Fitbit YAML configuration is being removed", diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 5421a652125..9aa6f633e63 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -241,7 +241,7 @@ async def test_sensors( ("devices_response", "monitored_resources"), [([DEVICE_RESPONSE_CHARGE_2, DEVICE_RESPONSE_ARIA_AIR], ["devices/battery"])], ) -async def test_device_battery_level( +async def test_device_battery( hass: HomeAssistant, fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], @@ -285,6 +285,43 @@ async def test_device_battery_level( assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_016713257" +@pytest.mark.parametrize( + ("devices_response", "monitored_resources"), + [([DEVICE_RESPONSE_CHARGE_2, DEVICE_RESPONSE_ARIA_AIR], ["devices/battery"])], +) +async def test_device_battery_level( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, +) -> None: + """Test battery level sensor for devices.""" + + assert await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + state = hass.states.get("sensor.charge_2_battery_level") + assert state + assert state.state == "60" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Charge 2 Battery level", + "device_class": "battery", + "unit_of_measurement": "%", + } + + state = hass.states.get("sensor.aria_air_battery_level") + assert state + assert state.state == "95" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Aria Air Battery level", + "device_class": "battery", + "unit_of_measurement": "%", + } + + @pytest.mark.parametrize( ( "monitored_resources", @@ -558,6 +595,7 @@ async def test_settings_scope_config_entry( states = hass.states.async_all() assert [s.entity_id for s in states] == [ "sensor.charge_2_battery", + "sensor.charge_2_battery_level", ] From 4a4d2ad743f59cbd536bf8a07a11e128902331fe Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 2 Nov 2023 10:57:00 +0100 Subject: [PATCH 155/982] Fix mqtt config validation error handling (#103210) * Fix MQTT config check * Fix handling invalid enity_category for sensors * Improve docstr * Update comment * Use correct util for yaml dump --- .../components/mqtt/binary_sensor.py | 10 ++++- homeassistant/components/mqtt/climate.py | 6 +-- homeassistant/components/mqtt/fan.py | 6 +-- homeassistant/components/mqtt/humidifier.py | 6 +-- homeassistant/components/mqtt/mixins.py | 19 ++++++++-- homeassistant/components/mqtt/sensor.py | 4 +- homeassistant/components/mqtt/text.py | 4 +- .../mqtt/test_alarm_control_panel.py | 2 +- tests/components/mqtt/test_climate.py | 2 +- tests/components/mqtt/test_fan.py | 4 +- tests/components/mqtt/test_init.py | 38 ++++++++++++++++++- tests/components/mqtt/test_text.py | 4 +- 12 files changed, 80 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 7ab2e9ebf90..a89fb8a22fc 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -42,6 +42,7 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, + validate_sensor_entity_category, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -55,7 +56,7 @@ DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False CONF_EXPIRE_AFTER = "expire_after" -PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, @@ -67,7 +68,12 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) +DISCOVERY_SCHEMA = vol.All( + validate_sensor_entity_category, + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), +) + +PLATFORM_SCHEMA_MODERN = vol.All(validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE) async def async_setup_entry( diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index dae768a1359..358fa6eb675 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -232,16 +232,16 @@ TOPIC_KEYS = ( def valid_preset_mode_configuration(config: ConfigType) -> ConfigType: """Validate that the preset mode reset payload is not one of the preset modes.""" if PRESET_NONE in config[CONF_PRESET_MODES_LIST]: - raise ValueError("preset_modes must not include preset mode 'none'") + raise vol.Invalid("preset_modes must not include preset mode 'none'") return config def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: """Validate a target_humidity range configuration, throws otherwise.""" if config[CONF_HUMIDITY_MIN] >= config[CONF_HUMIDITY_MAX]: - raise ValueError("target_humidity_max must be > target_humidity_min") + raise vol.Invalid("target_humidity_max must be > target_humidity_min") if config[CONF_HUMIDITY_MAX] > 100: - raise ValueError("max_humidity must be <= 100") + raise vol.Invalid("max_humidity must be <= 100") return config diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 02192676784..0e9e7d708e9 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -116,16 +116,16 @@ _LOGGER = logging.getLogger(__name__) def valid_speed_range_configuration(config: ConfigType) -> ConfigType: """Validate that the fan speed_range configuration is valid, throws if it isn't.""" if config[CONF_SPEED_RANGE_MIN] == 0: - raise ValueError("speed_range_min must be > 0") + raise vol.Invalid("speed_range_min must be > 0") if config[CONF_SPEED_RANGE_MIN] >= config[CONF_SPEED_RANGE_MAX]: - raise ValueError("speed_range_max must be > speed_range_min") + raise vol.Invalid("speed_range_max must be > speed_range_min") return config def valid_preset_mode_configuration(config: ConfigType) -> ConfigType: """Validate that the preset mode reset payload is not one of the preset modes.""" if config[CONF_PAYLOAD_RESET_PRESET_MODE] in config[CONF_PRESET_MODES_LIST]: - raise ValueError("preset_modes must not contain payload_reset_preset_mode") + raise vol.Invalid("preset_modes must not contain payload_reset_preset_mode") return config diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 77a74b15197..75a74a0dcaa 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -102,7 +102,7 @@ _LOGGER = logging.getLogger(__name__) def valid_mode_configuration(config: ConfigType) -> ConfigType: """Validate that the mode reset payload is not one of the available modes.""" if config[CONF_PAYLOAD_RESET_MODE] in config[CONF_AVAILABLE_MODES_LIST]: - raise ValueError("modes must not contain payload_reset_mode") + raise vol.Invalid("modes must not contain payload_reset_mode") return config @@ -113,9 +113,9 @@ def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: throws if it isn't. """ if config[CONF_TARGET_HUMIDITY_MIN] >= config[CONF_TARGET_HUMIDITY_MAX]: - raise ValueError("target_humidity_max must be > target_humidity_min") + raise vol.Invalid("target_humidity_max must be > target_humidity_min") if config[CONF_TARGET_HUMIDITY_MAX] > 100: - raise ValueError("max_humidity must be <= 100") + raise vol.Invalid("max_humidity must be <= 100") return config diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 908e3c768b8..91a5511001b 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -9,7 +9,6 @@ import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final import voluptuous as vol -import yaml from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,6 +27,7 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, + EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import ( @@ -63,6 +63,7 @@ from homeassistant.helpers.typing import ( UndefinedType, ) from homeassistant.util.json import json_loads +from homeassistant.util.yaml import dump as yaml_dump from . import debug_info, subscription from .client import async_publish @@ -207,6 +208,16 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType ) +def validate_sensor_entity_category(config: ConfigType) -> ConfigType: + """Check the sensor's entity category is not set to `config` which is invalid for sensors.""" + if ( + CONF_ENTITY_CATEGORY in config + and config[CONF_ENTITY_CATEGORY] == EntityCategory.CONFIG + ): + raise vol.Invalid("Entity category `config` is invalid") + return config + + MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE), vol.Schema( @@ -404,8 +415,8 @@ async def async_setup_entity_entry_helper( error = str(ex) config_file = getattr(yaml_config, "__config_file__", "?") line = getattr(yaml_config, "__line__", "?") - issue_id = hex(hash(frozenset(yaml_config.items()))) - yaml_config_str = yaml.dump(dict(yaml_config)) + issue_id = hex(hash(frozenset(yaml_config))) + yaml_config_str = yaml_dump(yaml_config) learn_more_url = ( f"https://www.home-assistant.io/integrations/{domain}.mqtt/" ) @@ -427,7 +438,7 @@ async def async_setup_entity_entry_helper( translation_key="invalid_platform_config", ) _LOGGER.error( - "%s for manual configured MQTT %s item, in %s, line %s Got %s", + "%s for manually configured MQTT %s item, in %s, line %s Got %s", error, domain, config_file, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 93151c51542..e1c7ba64aba 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -44,6 +44,7 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, + validate_sensor_entity_category, write_state_on_attr_change, ) from .models import ( @@ -70,7 +71,6 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False - _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), @@ -88,6 +88,7 @@ PLATFORM_SCHEMA_MODERN = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), + validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE, ) @@ -95,6 +96,7 @@ DISCOVERY_SCHEMA = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), + validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index f6aeac3be7c..da93a6b619e 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -71,9 +71,9 @@ MQTT_TEXT_ATTRIBUTES_BLOCKED = frozenset( def valid_text_size_configuration(config: ConfigType) -> ConfigType: """Validate that the text length configuration is valid, throws if it isn't.""" if config[CONF_MIN] >= config[CONF_MAX]: - raise ValueError("text length min must be >= max") + raise vol.Invalid("text length min must be >= max") if config[CONF_MAX] > MAX_LENGTH_STATE_STATE: - raise ValueError(f"max text length must be <= {MAX_LENGTH_STATE_STATE}") + raise vol.Invalid(f"max text length must be <= {MAX_LENGTH_STATE_STATE}") return config diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 0d5c9ee2e8d..40049431edb 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1297,7 +1297,7 @@ async def test_reload_after_invalid_config( assert hass.states.get("alarm_control_panel.test") is None assert ( "extra keys not allowed @ data['invalid_topic'] for " - "manual configured MQTT alarm_control_panel item, " + "manually configured MQTT alarm_control_panel item, " "in ?, line ? Got {'name': 'test', 'invalid_topic': 'test-topic'}" in caplog.text ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 89eaf87fb3a..6d6c7475366 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -139,7 +139,7 @@ async def test_preset_none_in_preset_modes( ) -> None: """Test the preset mode payload reset configuration.""" assert await mqtt_mock_entry() - assert "not a valid value" in caplog.text + assert "preset_modes must not include preset mode 'none'" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6642d778f53..21d3bcce3a9 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1788,7 +1788,7 @@ async def test_attributes( }, False, None, - "not a valid value", + "speed_range_max must be > speed_range_min", ), ( "test14", @@ -1805,7 +1805,7 @@ async def test_attributes( }, False, None, - "not a valid value", + "speed_range_min must be > 0", ), ( "test15", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 2aa8de388b1..93d73094885 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2134,7 +2134,7 @@ async def test_setup_manual_mqtt_with_platform_key( """Test set up a manual MQTT item with a platform key.""" assert await mqtt_mock_entry() assert ( - "extra keys not allowed @ data['platform'] for manual configured MQTT light item" + "extra keys not allowed @ data['platform'] for manually configured MQTT light item" in caplog.text ) @@ -2151,6 +2151,42 @@ async def test_setup_manual_mqtt_with_invalid_config( assert "required key not provided" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "sensor": { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } + } + }, + { + mqtt.DOMAIN: { + "binary_sensor": { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } + } + }, + ], +) +@patch( + "homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR, Platform.SENSOR] +) +async def test_setup_manual_mqtt_with_invalid_entity_category( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test set up a manual sensor item with an invalid entity category.""" + assert await mqtt_mock_entry() + assert "Entity category `config` is invalid" in caplog.text + + @patch("homeassistant.components.mqtt.PLATFORMS", []) @pytest.mark.parametrize( ("mqtt_config_entry_data", "protocol"), diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 80f38dffcf9..a602f1e3065 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -211,7 +211,7 @@ async def test_attribute_validation_max_greater_then_min( ) -> None: """Test the validation of min and max configuration attributes.""" assert await mqtt_mock_entry() - assert "not a valid value" in caplog.text + assert "text length min must be >= max" in caplog.text @pytest.mark.parametrize( @@ -236,7 +236,7 @@ async def test_attribute_validation_max_not_greater_then_max_state_length( ) -> None: """Test the max value of of max configuration attribute.""" assert await mqtt_mock_entry() - assert "not a valid value" in caplog.text + assert "max text length must be <= 255" in caplog.text @pytest.mark.parametrize( From d18b2d8748cf8776af9c9b10cebae101dc282e63 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 2 Nov 2023 14:58:26 +0300 Subject: [PATCH 156/982] Shield service call from cancellation on REST API connection loss (#102657) * Shield service call from cancellation on connection loss * add test for timeout * Apply suggestions from code review * Apply suggestions from code review * fix merge * Apply suggestions from code review --- homeassistant/components/api/__init__.py | 15 ++++++++++---- tests/components/api/test_init.py | 25 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 077e5ec9093..a9efda90482 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,6 +1,6 @@ """Rest API for Home Assistant.""" import asyncio -from asyncio import timeout +from asyncio import shield, timeout from collections.abc import Collection from functools import lru_cache from http import HTTPStatus @@ -62,6 +62,7 @@ ATTR_VERSION = "version" DOMAIN = "api" STREAM_PING_PAYLOAD = "ping" STREAM_PING_INTERVAL = 50 # seconds +SERVICE_WAIT_TIMEOUT = 10 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -388,11 +389,17 @@ class APIDomainServicesView(HomeAssistantView): ) try: - await hass.services.async_call( - domain, service, data, blocking=True, context=context - ) + async with timeout(SERVICE_WAIT_TIMEOUT): + # shield the service call from cancellation on connection drop + await shield( + hass.services.async_call( + domain, service, data, blocking=True, context=context + ) + ) except (vol.Invalid, ServiceNotFound) as ex: raise HTTPBadRequest() from ex + except TimeoutError: + pass finally: cancel_listen() diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 2d570540341..f97b55c3ede 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -352,6 +352,31 @@ async def test_api_call_service_with_data( assert state["attributes"] == {"data": 1} +async def test_api_call_service_timeout( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if the API does not fail on long running services.""" + test_value = [] + + fut = hass.loop.create_future() + + async def listener(service_call): + """Wait and return after mock_api_client.post finishes.""" + value = await fut + test_value.append(value) + + hass.services.async_register("test_domain", "test_service", listener) + + with patch("homeassistant.components.api.SERVICE_WAIT_TIMEOUT", 0): + await mock_api_client.post("/api/services/test_domain/test_service") + assert len(test_value) == 0 + fut.set_result(1) + await hass.async_block_till_done() + + assert len(test_value) == 1 + assert test_value[0] == 1 + + async def test_api_template(hass: HomeAssistant, mock_api_client: TestClient) -> None: """Test the template API.""" hass.states.async_set("sensor.temperature", 10) From a0741c74b2e1395e353ddbfbbdc78b62f908b1c2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 2 Nov 2023 13:18:13 +0100 Subject: [PATCH 157/982] Remove icon in Random (#103235) --- homeassistant/components/random/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 8e77f026253..50f088f7043 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -65,7 +65,6 @@ async def async_setup_entry( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" - _attr_icon = "mdi:hanger" _state: int | None = None def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: From 401bb90215091b933671ab138315bd851d0a1aee Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 2 Nov 2023 14:40:27 +0100 Subject: [PATCH 158/982] Use shorthand attributes in Random (#103206) --- .../components/random/binary_sensor.py | 23 ++----------- homeassistant/components/random/sensor.py | 32 ++++--------------- 2 files changed, 10 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 9ada2ecd621..0c5b4a8b0dd 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -54,31 +54,14 @@ async def async_setup_entry( class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" - _state: bool | None = None - def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" - self._name = config.get(CONF_NAME) - self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_name = config.get(CONF_NAME) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) if entry_id: self._attr_unique_id = entry_id - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - async def async_update(self) -> None: """Get new state and update the sensor's state.""" - self._state = bool(getrandbits(1)) + self._attr_is_on = bool(getrandbits(1)) diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 50f088f7043..f1ca4290d83 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -65,39 +65,21 @@ async def async_setup_entry( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" - _state: int | None = None - def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" - self._name = config.get(CONF_NAME) + self._attr_name = config.get(CONF_NAME) self._minimum = config.get(CONF_MINIMUM, DEFAULT_MIN) self._maximum = config.get(CONF_MAXIMUM, DEFAULT_MAX) - self._unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_extra_state_attributes = { + ATTR_MAXIMUM: self._maximum, + ATTR_MINIMUM: self._minimum, + } if entry_id: self._attr_unique_id = entry_id - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the attributes of the sensor.""" - return {ATTR_MAXIMUM: self._maximum, ATTR_MINIMUM: self._minimum} - async def async_update(self) -> None: """Get a new number and updates the states.""" - self._state = randrange(self._minimum, self._maximum + 1) + self._attr_native_value = randrange(self._minimum, self._maximum + 1) From 194a799b0a5a188349a47253d4dcde26624b14fd Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 2 Nov 2023 16:47:33 +0100 Subject: [PATCH 159/982] Remove measurement flag from timestamp in gardena bluetooth (#103245) Remove measurement flag from timestamp --- homeassistant/components/gardena_bluetooth/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 396d8469ffc..495a1fcb1eb 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -88,7 +88,6 @@ DESCRIPTIONS = ( GardenaBluetoothSensorEntityDescription( key=Sensor.measurement_timestamp.uuid, translation_key="sensor_measurement_timestamp", - state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, char=Sensor.measurement_timestamp, From 4c3c86511be80c6c75a193a9d61d0cf7e2731e61 Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 2 Nov 2023 18:07:35 +0100 Subject: [PATCH 160/982] Fix fibaro event handling (#103199) --- homeassistant/components/fibaro/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index cdfa7f6a864..8b41c4f404f 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -186,12 +186,13 @@ class FibaroController: resolver = FibaroStateResolver(state) for event in resolver.get_events(): - fibaro_id = event.fibaro_id + # event does not always have a fibaro id, therefore it is + # essential that we first check for relevant event type if ( event.event_type.lower() == "centralsceneevent" - and fibaro_id in self._event_callbacks + and event.fibaro_id in self._event_callbacks ): - for callback in self._event_callbacks[fibaro_id]: + for callback in self._event_callbacks[event.fibaro_id]: callback(event) def register(self, device_id: int, callback: Any) -> None: From b12d99bd2b601a548a1b481db67b5aa6231e939c Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 2 Nov 2023 15:46:58 -0400 Subject: [PATCH 161/982] Bump pyenphase to 1.14.1 (#103239) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 0700bd4e71a..4cffcce2d5c 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.13.1"], + "requirements": ["pyenphase==1.14.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 711d7b75c11..c9d024c937c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1693,7 +1693,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.13.1 +pyenphase==1.14.1 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ca70434dc3..163dfe692ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1275,7 +1275,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.13.1 +pyenphase==1.14.1 # homeassistant.components.everlights pyeverlights==0.1.0 From 35e1ecec8da5954a4803527f14300e82c8f0b7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 2 Nov 2023 20:53:26 +0100 Subject: [PATCH 162/982] Update aioairzone-cloud to v0.3.2 (#103258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index eb959342122..bbc8a84a3dc 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.1"] + "requirements": ["aioairzone-cloud==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c9d024c937c..13e6524c4f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.1 +aioairzone-cloud==0.3.2 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 163dfe692ec..3e047f76dbb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.1 +aioairzone-cloud==0.3.2 # homeassistant.components.airzone aioairzone==0.6.9 From 45f5c2140232431ab1e5d976a22df000286dda5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Nov 2023 15:18:12 -0500 Subject: [PATCH 163/982] Speed up websocket and ingress with aiohttp-zlib-ng (#103247) --- homeassistant/components/http/__init__.py | 3 +++ homeassistant/components/http/manifest.json | 2 +- homeassistant/package_constraints.txt | 1 + pyproject.toml | 2 ++ requirements.txt | 2 ++ requirements_all.txt | 3 +++ requirements_test_all.txt | 3 +++ 7 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 122b7b79ce9..04a8c13bba2 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -23,6 +23,7 @@ from aiohttp.web_urldispatcher import ( UrlDispatcher, UrlMappingMatchInfo, ) +from aiohttp_zlib_ng import enable_zlib_ng from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -173,6 +174,8 @@ class ApiConfig: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" + enable_zlib_ng() + conf: ConfData | None = config.get(DOMAIN) if conf is None: diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index bce425adbdb..dffd1dd1d8c 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0"] + "requirements": ["aiohttp_cors==0.7.0", "aiohttp-zlib-ng==0.1.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a70bcf4524a..2843a1b418d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,5 @@ aiodiscover==1.5.1 +aiohttp-zlib-ng==0.1.1 aiohttp==3.8.5;python_version<'3.12' aiohttp==3.9.0b0;python_version>='3.12' aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 235e41a7cca..60557c3948e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ requires-python = ">=3.11.0" dependencies = [ "aiohttp==3.9.0b0;python_version>='3.12'", "aiohttp==3.8.5;python_version<'3.12'", + "aiohttp_cors==0.7.0", + "aiohttp-zlib-ng==0.1.1", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index df08234d4db..98d6e3864e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,8 @@ # Home Assistant Core aiohttp==3.9.0b0;python_version>='3.12' aiohttp==3.8.5;python_version<'3.12' +aiohttp_cors==0.7.0 +aiohttp-zlib-ng==0.1.1 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/requirements_all.txt b/requirements_all.txt index 13e6524c4f9..d1650fe803c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,6 +257,9 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.0.9 +# homeassistant.components.http +aiohttp-zlib-ng==0.1.1 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e047f76dbb..40728ea1603 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,6 +235,9 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.0.9 +# homeassistant.components.http +aiohttp-zlib-ng==0.1.1 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 From f15fb6cf5e928cdc4c0bab3a17b1920088e52aa8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Nov 2023 15:48:32 -0500 Subject: [PATCH 164/982] Reduce overhead to run event triggers (#103172) --- homeassistant/components/homeassistant/triggers/event.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index a4266a70add..be514fd24ad 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -142,7 +142,9 @@ async def async_attach_trigger( ) removes = [ - hass.bus.async_listen(event_type, handle_event, event_filter=filter_event) + hass.bus.async_listen( + event_type, handle_event, event_filter=filter_event, run_immediately=True + ) for event_type in event_types ] From 4e3ff45a5e15366a857c0cd0e06b18dd0140533a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 2 Nov 2023 21:59:25 +0100 Subject: [PATCH 165/982] Use constant instead of plain key name for device info attributes (#103188) * Use constant instead of plain key name for device info connections * Some more device info constant changes --- homeassistant/components/asuswrt/diagnostics.py | 2 +- homeassistant/components/elgato/entity.py | 4 ++-- homeassistant/components/fully_kiosk/entity.py | 3 ++- .../components/kostal_plenticore/diagnostics.py | 4 ++-- homeassistant/components/samsungtv/entity.py | 12 +++++++++--- .../components/yamaha_musiccast/__init__.py | 6 +++--- 6 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index 61de4c866db..0a3cc809c32 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -36,7 +36,7 @@ async def async_get_config_entry_diagnostics( device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) hass_device = device_registry.async_get_device( - identifiers=router.device_info["identifiers"] + identifiers=router.device_info[ATTR_IDENTIFIERS] ) if not hass_device: return data diff --git a/homeassistant/components/elgato/entity.py b/homeassistant/components/elgato/entity.py index 1bbd32f5b44..3f46b51d7b7 100644 --- a/homeassistant/components/elgato/entity.py +++ b/homeassistant/components/elgato/entity.py @@ -1,7 +1,7 @@ """Base entity for the Elgato integration.""" from __future__ import annotations -from homeassistant.const import CONF_MAC +from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -31,6 +31,6 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]): hw_version=str(coordinator.data.info.hardware_board_type), ) if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None: - self._attr_device_info["connections"] = { + self._attr_device_info[ATTR_CONNECTIONS] = { (CONNECTION_NETWORK_MAC, format_mac(mac)) } diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index 2fe367643ee..fcb6f35eb11 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -1,6 +1,7 @@ """Base entity for the Fully Kiosk Browser integration.""" from __future__ import annotations +from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -40,7 +41,7 @@ class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entit if "Mac" in coordinator.data and valid_global_mac_address( coordinator.data["Mac"] ): - device_info["connections"] = { + device_info[ATTR_CONNECTIONS] = { (CONNECTION_NETWORK_MAC, coordinator.data["Mac"]) } self._attr_device_info = device_info diff --git a/homeassistant/components/kostal_plenticore/diagnostics.py b/homeassistant/components/kostal_plenticore/diagnostics.py index 2e061d35528..eef9f05537f 100644 --- a/homeassistant/components/kostal_plenticore/diagnostics.py +++ b/homeassistant/components/kostal_plenticore/diagnostics.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import ATTR_IDENTIFIERS, CONF_PASSWORD from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -36,7 +36,7 @@ async def async_get_config_entry_diagnostics( } device_info = {**plenticore.device_info} - device_info["identifiers"] = REDACTED # contains serial number + device_info[ATTR_IDENTIFIERS] = REDACTED # contains serial number data["device"] = device_info return data diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index dbfd7c44730..384a7a21528 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -2,7 +2,13 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_NAME +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_IDENTIFIERS, + CONF_MAC, + CONF_MODEL, + CONF_NAME, +) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -28,8 +34,8 @@ class SamsungTVEntity(Entity): model=config_entry.data.get(CONF_MODEL), ) if self.unique_id: - self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)} if self._mac: - self._attr_device_info["connections"] = { + self._attr_device_info[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, self._mac) } diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 9e8b8fed530..307171487bc 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -10,7 +10,7 @@ from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import ATTR_CONNECTIONS, ATTR_VIA_DEVICE, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import ( @@ -176,12 +176,12 @@ class MusicCastDeviceEntity(MusicCastEntity): ) if self._zone_id == DEFAULT_ZONE: - device_info["connections"] = { + device_info[ATTR_CONNECTIONS] = { (CONNECTION_NETWORK_MAC, format_mac(mac)) for mac in self.coordinator.data.mac_addresses.values() } else: - device_info["via_device"] = (DOMAIN, self.coordinator.data.device_id) + device_info[ATTR_VIA_DEVICE] = (DOMAIN, self.coordinator.data.device_id) return device_info From e7db0bf34d510e9e5532c3388e76821659756135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Janu=C3=A1rio?= Date: Thu, 2 Nov 2023 22:32:46 +0000 Subject: [PATCH 166/982] add library logger info on ecoforest integration manifest (#103274) --- homeassistant/components/ecoforest/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json index 2ef33b2054b..cca44c5b2a9 100644 --- a/homeassistant/components/ecoforest/manifest.json +++ b/homeassistant/components/ecoforest/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecoforest", "iot_class": "local_polling", + "loggers": ["pyecoforest"], "requirements": ["pyecoforest==0.4.0"] } From 4a117c0a1ebe4fc1b729f7341866fb24603a346d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 3 Nov 2023 00:57:48 +0000 Subject: [PATCH 167/982] Add buttons to connect/disconnect the Idasen Desk (#102433) Co-authored-by: J. Nick Koston --- .../components/idasen_desk/__init__.py | 2 +- .../components/idasen_desk/button.py | 101 ++++++++++++++++++ tests/components/idasen_desk/test_buttons.py | 33 ++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/idasen_desk/button.py create mode 100644 tests/components/idasen_desk/test_buttons.py diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 9496752dce7..0a17ebec96c 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.COVER] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py new file mode 100644 index 00000000000..6cae9a42895 --- /dev/null +++ b/homeassistant/components/idasen_desk/button.py @@ -0,0 +1,101 @@ +"""Representation of Idasen Desk buttons.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any, Final + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DeskData, IdasenDeskCoordinator +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class IdasenDeskButtonDescriptionMixin: + """Mixin to describe a IdasenDesk button entity.""" + + press_action: Callable[ + [IdasenDeskCoordinator], Callable[[], Coroutine[Any, Any, Any]] + ] + + +@dataclass +class IdasenDeskButtonDescription( + ButtonEntityDescription, IdasenDeskButtonDescriptionMixin +): + """Class to describe a IdasenDesk button entity.""" + + +BUTTONS: Final = [ + IdasenDeskButtonDescription( + key="connect", + name="Connect", + icon="mdi:bluetooth-connect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.async_connect, + ), + IdasenDeskButtonDescription( + key="disconnect", + name="Disconnect", + icon="mdi:bluetooth-off", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.async_disconnect, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set buttons for device.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + IdasenDeskButton(data.address, data.device_info, data.coordinator, button) + for button in BUTTONS + ) + + +class IdasenDeskButton(ButtonEntity): + """Defines a IdasenDesk button.""" + + entity_description: IdasenDeskButtonDescription + _attr_has_entity_name = True + + def __init__( + self, + address: str, + device_info: DeviceInfo, + coordinator: IdasenDeskCoordinator, + description: IdasenDeskButtonDescription, + ) -> None: + """Initialize the IdasenDesk button entity.""" + self.entity_description = description + + self._attr_unique_id = f"{self.entity_description.key}-{address}" + self._attr_device_info = device_info + self._address = address + self._coordinator = coordinator + + async def async_press(self) -> None: + """Triggers the IdasenDesk button press service.""" + _LOGGER.debug( + "Trigger %s for %s", + self.entity_description.key, + self._address, + ) + await self.entity_description.press_action(self._coordinator)() diff --git a/tests/components/idasen_desk/test_buttons.py b/tests/components/idasen_desk/test_buttons.py new file mode 100644 index 00000000000..d576b2fe580 --- /dev/null +++ b/tests/components/idasen_desk/test_buttons.py @@ -0,0 +1,33 @@ +"""Test the IKEA Idasen Desk connection buttons.""" +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_connect_button( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test pressing the connect button.""" + await init_integration(hass) + + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_connect"}, blocking=True + ) + assert mock_desk_api.connect.call_count == 2 + + +async def test_disconnect_button( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test pressing the disconnect button.""" + await init_integration(hass) + mock_desk_api.is_connected = True + + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_disconnect"}, blocking=True + ) + mock_desk_api.disconnect.assert_called_once() From b86f3be51006e395c644026cb31fc98acb57b0a8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 3 Nov 2023 02:00:34 +0100 Subject: [PATCH 168/982] Optmize timing excecutor timeout test (#103276) --- tests/util/test_executor.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 763efa494e7..076864c65c4 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -77,19 +77,17 @@ async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt( async def test_overall_timeout_reached(caplog: pytest.LogCaptureFixture) -> None: """Test that shutdown moves on when the overall timeout is reached.""" - iexecutor = InterruptibleThreadPoolExecutor() - def _loop_sleep_in_executor(): time.sleep(1) - for _ in range(6): - iexecutor.submit(_loop_sleep_in_executor) - - start = time.monotonic() with patch.object(executor, "EXECUTOR_SHUTDOWN_TIMEOUT", 0.5): + iexecutor = InterruptibleThreadPoolExecutor() + for _ in range(6): + iexecutor.submit(_loop_sleep_in_executor) + start = time.monotonic() iexecutor.shutdown() - finish = time.monotonic() + finish = time.monotonic() - assert finish - start < 1.2 + assert finish - start < 1.3 iexecutor.shutdown() From 06c9719cd6bbdadd4e591bbce385927702128a63 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 3 Nov 2023 02:37:35 +0100 Subject: [PATCH 169/982] Support multiple responses for service calls (#96370) * add supports_response to platform entity services * support multiple entities in entity_service_call * support legacy response format for service calls * revert changes to script/shell_command * add back test for multiple responses for legacy service * remove SupportsResponse.ONLY_LEGACY * Apply suggestion Co-authored-by: Allen Porter * test for entity_id remove None * revert Apply suggestion * return EntityServiceResponse from _handle_entity_call * Use asyncio.gather * EntityServiceResponse not Optional * styling --------- Co-authored-by: Allen Porter --- homeassistant/components/calendar/__init__.py | 2 +- homeassistant/components/weather/__init__.py | 2 +- homeassistant/core.py | 9 +- homeassistant/helpers/entity_component.py | 39 +++++- homeassistant/helpers/entity_platform.py | 9 +- homeassistant/helpers/service.py | 42 +++--- tests/helpers/test_entity_component.py | 87 +++++++++++- tests/helpers/test_entity_platform.py | 124 +++++++++++++++++- 8 files changed, 277 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 65a61e71d3a..2be0bd9a04b 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -300,7 +300,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_create_event, required_features=[CalendarEntityFeature.CREATE_EVENT], ) - component.async_register_entity_service( + component.async_register_legacy_entity_service( SERVICE_LIST_EVENTS, SERVICE_LIST_EVENTS_SCHEMA, async_list_events_service, diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 648201f16d2..d04daf2b160 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -210,7 +210,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) - component.async_register_entity_service( + component.async_register_legacy_entity_service( SERVICE_GET_FORECAST, {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, async_get_forecast_service, diff --git a/homeassistant/core.py b/homeassistant/core.py index 01a3dd7fbe6..40e9da376d5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -134,6 +134,7 @@ DOMAIN = "homeassistant" BLOCK_LOG_TIMEOUT = 60 ServiceResponse = JsonObjectType | None +EntityServiceResponse = dict[str, ServiceResponse] class ConfigSource(enum.StrEnum): @@ -1773,7 +1774,10 @@ class Service: def __init__( self, - func: Callable[[ServiceCall], Coroutine[Any, Any, ServiceResponse] | None], + func: Callable[ + [ServiceCall], + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] | None, + ], schema: vol.Schema | None, domain: str, service: str, @@ -1882,7 +1886,8 @@ class ServiceRegistry: domain: str, service: str, service_func: Callable[ - [ServiceCall], Coroutine[Any, Any, ServiceResponse] | None + [ServiceCall], + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] | None, ], schema: vol.Schema | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index af1b87ec0fa..ddd46759259 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -20,6 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import ( + EntityServiceResponse, Event, HomeAssistant, ServiceCall, @@ -217,6 +218,40 @@ class EntityComponent(Generic[_EntityT]): self.hass, self.entities, service_call, expand_group ) + @callback + def async_register_legacy_entity_service( + self, + name: str, + schema: dict[str | vol.Marker, Any] | vol.Schema, + func: str | Callable[..., Any], + required_features: list[int] | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, + ) -> None: + """Register an entity service with a legacy response format.""" + if isinstance(schema, dict): + schema = cv.make_entity_service_schema(schema) + + async def handle_service( + call: ServiceCall, + ) -> ServiceResponse: + """Handle the service.""" + + result = await service.entity_service_call( + self.hass, self._platforms.values(), func, call, required_features + ) + + if result: + if len(result) > 1: + raise HomeAssistantError( + "Deprecated service call matched more than one entity" + ) + return result.popitem()[1] + return None + + self.hass.services.async_register( + self.domain, name, handle_service, schema, supports_response + ) + @callback def async_register_entity_service( self, @@ -230,7 +265,9 @@ class EntityComponent(Generic[_EntityT]): if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service(call: ServiceCall) -> ServiceResponse: + async def handle_service( + call: ServiceCall, + ) -> EntityServiceResponse | None: """Handle the service.""" return await service.entity_service_call( self.hass, self._platforms.values(), func, call, required_features diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c164e3b1052..388c00bd177 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -20,8 +20,10 @@ from homeassistant.core import ( CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, + EntityServiceResponse, HomeAssistant, ServiceCall, + SupportsResponse, callback, split_entity_id, valid_entity_id, @@ -814,6 +816,7 @@ class EntityPlatform: schema: dict[str, Any] | vol.Schema, func: str | Callable[..., Any], required_features: Iterable[int] | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register an entity service. @@ -825,9 +828,9 @@ class EntityPlatform: if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service(call: ServiceCall) -> None: + async def handle_service(call: ServiceCall) -> EntityServiceResponse | None: """Handle the service.""" - await service.entity_service_call( + return await service.entity_service_call( self.hass, [ plf @@ -840,7 +843,7 @@ class EntityPlatform: ) self.hass.services.async_register( - self.platform_name, name, handle_service, schema + self.platform_name, name, handle_service, schema, supports_response ) async def _update_entity_states(self, now: datetime) -> None: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 4532e1a00ae..4cb8852414b 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import ( Context, + EntityServiceResponse, HomeAssistant, ServiceCall, ServiceResponse, @@ -790,7 +791,7 @@ async def entity_service_call( func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], call: ServiceCall, required_features: Iterable[int] | None = None, -) -> ServiceResponse | None: +) -> EntityServiceResponse | None: """Handle an entity service call. Calls all platforms simultaneously. @@ -870,10 +871,9 @@ async def entity_service_call( return None if len(entities) == 1: - # Single entity case avoids creating tasks and allows returning - # ServiceResponse + # Single entity case avoids creating task entity = entities[0] - response_data = await _handle_entity_call( + single_response = await _handle_entity_call( hass, entity, func, data, call.context ) if entity.should_poll: @@ -881,27 +881,25 @@ async def entity_service_call( # Set context again so it's there when we update entity.async_set_context(call.context) await entity.async_update_ha_state(True) - return response_data if return_response else None + return {entity.entity_id: single_response} if return_response else None - if return_response: - raise HomeAssistantError( - "Service call requested response data but matched more than one entity" - ) - - done, pending = await asyncio.wait( - [ - asyncio.create_task( - entity.async_request_call( - _handle_entity_call(hass, entity, func, data, call.context) - ) + # Use asyncio.gather here to ensure the returned results + # are in the same order as the entities list + results: list[ServiceResponse] = await asyncio.gather( + *[ + entity.async_request_call( + _handle_entity_call(hass, entity, func, data, call.context) ) for entity in entities - ] + ], + return_exceptions=True, ) - assert not pending - for task in done: - task.result() # pop exception if have + response_data: EntityServiceResponse = {} + for entity, result in zip(entities, results): + if isinstance(result, Exception): + raise result + response_data[entity.entity_id] = result tasks: list[asyncio.Task[None]] = [] @@ -920,7 +918,7 @@ async def entity_service_call( for future in done: future.result() # pop exception if have - return None + return response_data if return_response and response_data else None async def _handle_entity_call( @@ -943,7 +941,7 @@ async def _handle_entity_call( # Guard because callback functions do not return a task when passed to # async_run_job. - result: ServiceResponse | None = None + result: ServiceResponse = None if task is not None: result = await task diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 4119ccc6e85..b5cda6770c5 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -531,7 +531,7 @@ async def test_register_entity_service(hass: HomeAssistant) -> None: async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: - """Test an enttiy service that does not support response data.""" + """Test an entity service that does support response data.""" entity = MockEntity(entity_id=f"{DOMAIN}.entity") async def generate_response( @@ -554,24 +554,25 @@ async def test_register_entity_service_response_data(hass: HomeAssistant) -> Non response_data = await hass.services.async_call( DOMAIN, "hello", - service_data={"entity_id": entity.entity_id, "some": "data"}, + service_data={"some": "data"}, + target={"entity_id": [entity.entity_id]}, blocking=True, return_response=True, ) - assert response_data == {"response-key": "response-value"} + assert response_data == {f"{DOMAIN}.entity": {"response-key": "response-value"}} async def test_register_entity_service_response_data_multiple_matches( hass: HomeAssistant, ) -> None: - """Test asking for service response data but matching many entities.""" + """Test asking for service response data and matching many entities.""" entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") async def generate_response( target: MockEntity, call: ServiceCall ) -> ServiceResponse: - raise ValueError("Should not be invoked") + return {"response-key": f"response-value-{target.entity_id}"} component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_setup({}) @@ -579,7 +580,80 @@ async def test_register_entity_service_response_data_multiple_matches( component.async_register_entity_service( "hello", - {}, + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + DOMAIN, + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + assert response_data == { + f"{DOMAIN}.entity1": {"response-key": f"response-value-{DOMAIN}.entity1"}, + f"{DOMAIN}.entity2": {"response-key": f"response-value-{DOMAIN}.entity2"}, + } + + +async def test_register_entity_service_response_data_multiple_matches_raises( + hass: HomeAssistant, +) -> None: + """Test asking for service response data and matching many entities raises exceptions.""" + entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") + entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + if target.entity_id == f"{DOMAIN}.entity1": + raise RuntimeError("Something went wrong") + return {"response-key": f"response-value-{target.entity_id}"} + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + await component.async_add_entities([entity1, entity2]) + + component.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + with pytest.raises(RuntimeError, match="Something went wrong"): + await hass.services.async_call( + DOMAIN, + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + + +async def test_legacy_register_entity_service_response_data_multiple_matches( + hass: HomeAssistant, +) -> None: + """Test asking for legacy service response data but matching many entities.""" + entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") + entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + return {"response-key": "response-value"} + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + await component.async_add_entities([entity1, entity2]) + + component.async_register_legacy_entity_service( + "hello", + {"some": str}, generate_response, supports_response=SupportsResponse.ONLY, ) @@ -588,6 +662,7 @@ async def test_register_entity_service_response_data_multiple_matches( await hass.services.async_call( DOMAIN, "hello", + service_data={"some": "data"}, target={"entity_id": [entity1.entity_id, entity2.entity_id]}, blocking=True, return_response=True, diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 0bbfedb8926..7ccbd5e0f28 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -9,7 +9,14 @@ from unittest.mock import ANY, Mock, patch import pytest from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, PERCENTAGE -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import ( + CoreState, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import ( device_registry as dr, @@ -1491,6 +1498,121 @@ async def test_platforms_sharing_services(hass: HomeAssistant) -> None: assert entity2 in entities +async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: + """Test an entity service that does supports response data.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": "response-value"} + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity = MockEntity(entity_id="mock_integration.entity") + await entity_platform.async_add_entities([entity]) + + entity_platform.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity.entity_id]}, + blocking=True, + return_response=True, + ) + assert response_data == { + "mock_integration.entity": {"response-key": "response-value"} + } + + +async def test_register_entity_service_response_data_multiple_matches( + hass: HomeAssistant, +) -> None: + """Test an entity service that does supports response data and matching many entities.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": f"response-value-{target.entity_id}"} + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id="mock_integration.entity1") + entity2 = MockEntity(entity_id="mock_integration.entity2") + await entity_platform.async_add_entities([entity1, entity2]) + + entity_platform.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + assert response_data == { + "mock_integration.entity1": { + "response-key": "response-value-mock_integration.entity1" + }, + "mock_integration.entity2": { + "response-key": "response-value-mock_integration.entity2" + }, + } + + +async def test_register_entity_service_response_data_multiple_matches_raises( + hass: HomeAssistant, +) -> None: + """Test entity service response matching many entities raises.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + if target.entity_id == "mock_integration.entity1": + raise RuntimeError("Something went wrong") + return {"response-key": f"response-value-{target.entity_id}"} + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id="mock_integration.entity1") + entity2 = MockEntity(entity_id="mock_integration.entity2") + await entity_platform.async_add_entities([entity1, entity2]) + + entity_platform.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + with pytest.raises(RuntimeError, match="Something went wrong"): + await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + + async def test_invalid_entity_id(hass: HomeAssistant) -> None: """Test specifying an invalid entity id.""" platform = MockEntityPlatform(hass) From a95aa4e15f6724c42db1df9d7bc454f15d345aaa Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 2 Nov 2023 19:48:56 -0700 Subject: [PATCH 170/982] Add config flow to CalDAV (#103215) * Initial caldav config flow with broken calendar platform * Set up calendar entities * Remove separate caldav entity * Update tests after merge * Readbility improvements * Address lint issues * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Add checking for duplicate configuration entries * Use verify SSL as input into caldav and simplify test setup --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/caldav/__init__.py | 60 ++++ homeassistant/components/caldav/calendar.py | 70 ++++- .../components/caldav/config_flow.py | 127 ++++++++ homeassistant/components/caldav/const.py | 5 + homeassistant/components/caldav/manifest.json | 1 + homeassistant/components/caldav/strings.json | 34 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/caldav/conftest.py | 77 +++++ tests/components/caldav/test_calendar.py | 51 ++-- tests/components/caldav/test_config_flow.py | 284 ++++++++++++++++++ tests/components/caldav/test_init.py | 69 +++++ 12 files changed, 752 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/caldav/config_flow.py create mode 100644 homeassistant/components/caldav/const.py create mode 100644 homeassistant/components/caldav/strings.json create mode 100644 tests/components/caldav/conftest.py create mode 100644 tests/components/caldav/test_config_flow.py create mode 100644 tests/components/caldav/test_init.py diff --git a/homeassistant/components/caldav/__init__.py b/homeassistant/components/caldav/__init__.py index 6fe9a8d4d19..d62ff3eb5ce 100644 --- a/homeassistant/components/caldav/__init__.py +++ b/homeassistant/components/caldav/__init__.py @@ -1 +1,61 @@ """The caldav component.""" + +import logging + +import caldav +from caldav.lib.error import AuthorizationError, DAVError +import requests + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up CalDAV from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + client = caldav.DAVClient( + entry.data[CONF_URL], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ssl_verify_cert=entry.data[CONF_VERIFY_SSL], + ) + try: + await hass.async_add_executor_job(client.principal) + except AuthorizationError as err: + if err.reason == "Unauthorized": + raise ConfigEntryAuthFailed("Credentials error from CalDAV server") from err + # AuthorizationError can be raised if the url is incorrect or + # on some other unexpected server response. + _LOGGER.warning("Unexpected CalDAV server response: %s", err) + return False + except requests.ConnectionError as err: + raise ConfigEntryNotReady("Connection error from CalDAV server") from err + except DAVError as err: + raise ConfigEntryNotReady("CalDAV client error") from err + + hass.data[DOMAIN][entry.entry_id] = client + + 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/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 14c9626c264..73764d60419 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -14,6 +14,7 @@ from homeassistant.components.calendar import ( CalendarEvent, is_offset_reached, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -28,6 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import CalDavUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -38,6 +40,10 @@ CONF_CALENDAR = "calendar" CONF_SEARCH = "search" CONF_DAYS = "days" +# Number of days to look ahead for next event when configured by ConfigEntry +CONFIG_ENTRY_DEFAULT_DAYS = 7 + +OFFSET = "!!" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -106,7 +112,9 @@ def setup_platform( include_all_day=True, search=cust_calendar[CONF_SEARCH], ) - calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator)) + calendar_devices.append( + WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True) + ) # Create a default calendar if there was no custom one for all calendars # that support events. @@ -131,20 +139,61 @@ def setup_platform( include_all_day=False, search=None, ) - calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator)) + calendar_devices.append( + WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True) + ) add_entities(calendar_devices, True) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the CalDav calendar platform for a config entry.""" + client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id] + calendars = await hass.async_add_executor_job(client.principal().calendars) + async_add_entities( + ( + WebDavCalendarEntity( + calendar.name, + generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), + CalDavUpdateCoordinator( + hass, + calendar=calendar, + days=CONFIG_ENTRY_DEFAULT_DAYS, + include_all_day=True, + search=None, + ), + unique_id=f"{entry.entry_id}-{calendar.id}", + ) + for calendar in calendars + if calendar.name + ), + True, + ) + + class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity): """A device for getting the next Task from a WebDav Calendar.""" - def __init__(self, name, entity_id, coordinator): + def __init__( + self, + name: str, + entity_id: str, + coordinator: CalDavUpdateCoordinator, + unique_id: str | None = None, + supports_offset: bool = False, + ) -> None: """Create the WebDav Calendar Event Device.""" super().__init__(coordinator) self.entity_id = entity_id self._event: CalendarEvent | None = None self._attr_name = name + if unique_id is not None: + self._attr_unique_id = unique_id + self._supports_offset = supports_offset @property def event(self) -> CalendarEvent | None: @@ -161,13 +210,14 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE def _handle_coordinator_update(self) -> None: """Update event data.""" self._event = self.coordinator.data - self._attr_extra_state_attributes = { - "offset_reached": is_offset_reached( - self._event.start_datetime_local, self.coordinator.offset - ) - if self._event - else False - } + if self._supports_offset: + self._attr_extra_state_attributes = { + "offset_reached": is_offset_reached( + self._event.start_datetime_local, self.coordinator.offset + ) + if self._event + else False + } super()._handle_coordinator_update() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/caldav/config_flow.py b/homeassistant/components/caldav/config_flow.py new file mode 100644 index 00000000000..f2fa51c7f60 --- /dev/null +++ b/homeassistant/components/caldav/config_flow.py @@ -0,0 +1,127 @@ +"""Configuration flow for CalDav.""" + +from collections.abc import Mapping +import logging +from typing import Any + +import caldav +from caldav.lib.error import AuthorizationError, DAVError +import requests +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for caldav.""" + + VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + if error := await self._test_connection(user_input): + errors["base"] = error + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def _test_connection(self, user_input: dict[str, Any]) -> str | None: + """Test the connection to the CalDAV server and return an error if any.""" + client = caldav.DAVClient( + user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ssl_verify_cert=user_input[CONF_VERIFY_SSL], + ) + try: + await self.hass.async_add_executor_job(client.principal) + except AuthorizationError as err: + _LOGGER.warning("Authorization Error connecting to CalDAV server: %s", err) + if err.reason == "Unauthorized": + return "invalid_auth" + # AuthorizationError can be raised if the url is incorrect or + # on some other unexpected server response. + return "cannot_connect" + except requests.ConnectionError as err: + _LOGGER.warning("Connection Error connecting to CalDAV server: %s", err) + return "cannot_connect" + except DAVError as err: + _LOGGER.warning("CalDAV client error: %s", err) + return "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + return None + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors = {} + assert self._reauth_entry + if user_input is not None: + user_input = {**self._reauth_entry.data, **user_input} + + if error := await self._test_connection(user_input): + errors["base"] = error + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + description_placeholders={ + CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME], + }, + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/caldav/const.py b/homeassistant/components/caldav/const.py new file mode 100644 index 00000000000..7a94a74c7a1 --- /dev/null +++ b/homeassistant/components/caldav/const.py @@ -0,0 +1,5 @@ +"""Constands for CalDAV.""" + +from typing import Final + +DOMAIN: Final = "caldav" diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 92e2f7e67d8..a7365515758 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -2,6 +2,7 @@ "domain": "caldav", "name": "CalDAV", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], diff --git a/homeassistant/components/caldav/strings.json b/homeassistant/components/caldav/strings.json new file mode 100644 index 00000000000..64fdf466b30 --- /dev/null +++ b/homeassistant/components/caldav/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Please enter your CalDAV server credentials" + }, + "reauth_confirm": { + "description": "The password for {username} is invalid.", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 48864fef3af..b7f112783ad 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -78,6 +78,7 @@ FLOWS = { "bsblan", "bthome", "buienradar", + "caldav", "canary", "cast", "cert_expiry", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f834f71bb07..be9d1f1bf5d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -765,7 +765,7 @@ "caldav": { "name": "CalDAV", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "canary": { diff --git a/tests/components/caldav/conftest.py b/tests/components/caldav/conftest.py new file mode 100644 index 00000000000..1c773d49166 --- /dev/null +++ b/tests/components/caldav/conftest.py @@ -0,0 +1,77 @@ +"""Test fixtures for caldav.""" +from collections.abc import Awaitable, Callable +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.caldav.const import DOMAIN +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_URL = "https://example.com/url-1" +TEST_USERNAME = "username-1" +TEST_PASSWORD = "password-1" + + +@pytest.fixture(name="platforms") +def mock_platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(name="calendars") +def mock_calendars() -> list[Mock]: + """Fixture to provide calendars returned by CalDAV client.""" + return [] + + +@pytest.fixture(name="dav_client", autouse=True) +def mock_dav_client(calendars: list[Mock]) -> Mock: + """Fixture to mock the DAVClient.""" + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.return_value.calendars.return_value = ( + calendars + ) + yield mock_client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: True, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platforms: list[str], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index b7c9ed32244..023dae3facd 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -3,14 +3,14 @@ from collections.abc import Awaitable, Callable import datetime from http import HTTPStatus from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock from caldav.objects import Event from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -300,6 +300,12 @@ TEST_ENTITY = "calendar.example" CALENDAR_NAME = "Example" +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set up config entry platforms.""" + return [Platform.CALENDAR] + + @pytest.fixture(name="tz") def mock_tz() -> str | None: """Fixture to specify the Home Assistant timezone to use during the test.""" @@ -331,18 +337,6 @@ def mock_calendars(calendar_names: list[str]) -> list[Mock]: return [_mock_calendar(name) for name in calendar_names] -@pytest.fixture(name="dav_client", autouse=True) -def mock_dav_client(calendars: list[Mock]) -> Mock: - """Fixture to mock the DAVClient.""" - with patch( - "homeassistant.components.caldav.calendar.caldav.DAVClient" - ) as mock_client: - mock_client.return_value.principal.return_value.calendars.return_value = ( - calendars - ) - yield mock_client - - @pytest.fixture def get_api_events( hass_client: ClientSessionGenerator, @@ -1067,10 +1061,7 @@ async def test_get_events_custom_calendars( ] ], ) -async def test_calendar_components( - hass: HomeAssistant, - dav_client: Mock, -) -> None: +async def test_calendar_components(hass: HomeAssistant) -> None: """Test that only calendars that support events are created.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) @@ -1094,3 +1085,27 @@ async def test_calendar_components( assert state assert state.name == "Calendar 4" assert state.state == STATE_OFF + + +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 30)) +async def test_setup_config_entry( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test a calendar entity from a config entry.""" + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "message": "This is an all day event", + "all_day": True, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day", + } diff --git a/tests/components/caldav/test_config_flow.py b/tests/components/caldav/test_config_flow.py new file mode 100644 index 00000000000..6af7d5c670c --- /dev/null +++ b/tests/components/caldav/test_config_flow.py @@ -0,0 +1,284 @@ +"""Test the CalDAV config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from caldav.lib.error import AuthorizationError, DAVError +import pytest +import requests + +from homeassistant import config_entries +from homeassistant.components.caldav.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_PASSWORD, TEST_URL, TEST_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test successful config flow setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == TEST_USERNAME + assert result2.get("data") == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (Exception(), "unknown"), + (requests.ConnectionError(), "cannot_connect"), + (DAVError(), "cannot_connect"), + (AuthorizationError(reason="Unauthorized"), "invalid_auth"), + (AuthorizationError(reason="Other"), "cannot_connect"), + ], +) +async def test_caldav_client_error( + hass: HomeAssistant, + side_effect: Exception, + expected_error: str, + dav_client: Mock, +) -> None: + """Test CalDav client errors during configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + dav_client.return_value.principal.side_effect = side_effect + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": expected_error} + + +async def test_reauth_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reauthentication configuration flow.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "password-2", + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + # Verify updated configuration entry + assert dict(config_entry.data) == { + CONF_URL: "https://example.com/url-1", + CONF_USERNAME: "username-1", + CONF_PASSWORD: "password-2", + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, + dav_client: Mock, +) -> None: + """Test a failure during reauthentication configuration flow.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + dav_client.return_value.principal.side_effect = DAVError + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "password-2", + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + # Complete the form and it succeeds this time + dav_client.return_value.principal.side_effect = None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "password-3", + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + # Verify updated configuration entry + assert dict(config_entry.data) == { + CONF_URL: "https://example.com/url-1", + CONF_USERNAME: "username-1", + CONF_PASSWORD: "password-3", + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("user_input"), + [ + { + CONF_URL: f"{TEST_URL}/different-path", + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + { + CONF_URL: TEST_URL, + CONF_USERNAME: f"{TEST_USERNAME}-different-user", + CONF_PASSWORD: TEST_PASSWORD, + }, + ], +) +async def test_multiple_config_entries( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, + user_input: dict[str, str], +) -> None: + """Test multiple configuration entries with unique settings.""" + + config_entry.add_to_hass(hass) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == user_input[CONF_USERNAME] + assert result2.get("data") == { + **user_input, + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 2 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + +@pytest.mark.parametrize( + ("user_input"), + [ + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: f"{TEST_PASSWORD}-different", + }, + ], +) +async def test_duplicate_config_entries( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, + user_input: dict[str, str], +) -> None: + """Test multiple configuration entries with the same settings.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "already_configured" diff --git a/tests/components/caldav/test_init.py b/tests/components/caldav/test_init.py new file mode 100644 index 00000000000..a37815a007c --- /dev/null +++ b/tests/components/caldav/test_init.py @@ -0,0 +1,69 @@ +"""Unit tests for the CalDav integration.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from caldav.lib.error import AuthorizationError, DAVError +import pytest +import requests + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading of the config entry.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + + with patch("homeassistant.components.caldav.config_flow.caldav.DAVClient"): + assert await setup_integration() + + assert config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state", "expected_flows"), + [ + (Exception(), ConfigEntryState.SETUP_ERROR, []), + (requests.ConnectionError(), ConfigEntryState.SETUP_RETRY, []), + (DAVError(), ConfigEntryState.SETUP_RETRY, []), + ( + AuthorizationError(reason="Unauthorized"), + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + (AuthorizationError(reason="Other"), ConfigEntryState.SETUP_ERROR, []), + ], +) +async def test_client_failure( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry | None, + side_effect: Exception, + expected_state: ConfigEntryState, + expected_flows: list[str], +) -> None: + """Test CalDAV client failures in setup.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + + with patch( + "homeassistant.components.caldav.config_flow.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.side_effect = side_effect + assert not await setup_integration() + + assert config_entry.state == expected_state + + flows = hass.config_entries.flow.async_progress() + assert [flow.get("step_id") for flow in flows] == expected_flows From 379c75ea1bddc16546710ac44f07ada234aeea71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Nov 2023 22:00:43 -0500 Subject: [PATCH 171/982] Bump yalexs-ble to 2.3.2 (#103267) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 50df1f4bd1d..aacebb4bb5c 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.1"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.2"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 8d15fbb9a9f..be388ec563c 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.3.1"] + "requirements": ["yalexs-ble==2.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d1650fe803c..99d4ceb1ac0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2761,7 +2761,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.1 +yalexs-ble==2.3.2 # homeassistant.components.august yalexs==1.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40728ea1603..341d4b3f735 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2061,7 +2061,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.1 +yalexs-ble==2.3.2 # homeassistant.components.august yalexs==1.10.0 From 12e1acfcfc52244aee0c064f651b3aa7d1bcc349 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Nov 2023 05:53:38 +0100 Subject: [PATCH 172/982] Cleanup weather test (#103090) * Cleanup weather test * rename * Clean off not used MockWeatherCompat * conftest * more cleanup * Fin mod tests * fix others --- tests/components/weather/__init__.py | 115 ++- tests/components/weather/conftest.py | 22 + tests/components/weather/test_init.py | 667 ++++++++++-------- tests/components/weather/test_recorder.py | 55 +- .../components/weather/test_websocket_api.py | 52 +- .../custom_components/test/weather.py | 78 +- .../test_weather/__init__.py | 1 - .../test_weather/manifest.json | 9 - .../custom_components/test_weather/weather.py | 210 ------ 9 files changed, 547 insertions(+), 662 deletions(-) create mode 100644 tests/components/weather/conftest.py delete mode 100644 tests/testing_config/custom_components/test_weather/__init__.py delete mode 100644 tests/testing_config/custom_components/test_weather/manifest.json delete mode 100644 tests/testing_config/custom_components/test_weather/weather.py diff --git a/tests/components/weather/__init__.py b/tests/components/weather/__init__.py index 91097dfae14..35a818735d0 100644 --- a/tests/components/weather/__init__.py +++ b/tests/components/weather/__init__.py @@ -1,14 +1,71 @@ """The tests for Weather platforms.""" -from homeassistant.components.weather import ATTR_CONDITION_SUNNY -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from typing import Any +from homeassistant.components.weather import ( + ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_DEW_POINT, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, + DOMAIN, + Forecast, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_integration, + mock_platform, +) from tests.testing_config.custom_components.test import weather as WeatherPlatform -async def create_entity(hass: HomeAssistant, **kwargs): +class MockWeatherTest(WeatherPlatform.MockWeather): + """Mock weather class.""" + + def __init__(self, **values: Any) -> None: + """Initialize.""" + super().__init__(**values) + self.forecast_list: list[Forecast] | None = [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] + + +async def create_entity( + hass: HomeAssistant, + mock_weather: WeatherPlatform.MockWeather, + manifest_extra: dict[str, Any] | None, + **kwargs, +) -> WeatherPlatform.MockWeather: """Create the weather entity to run tests on.""" kwargs = { "native_temperature": None, @@ -16,17 +73,47 @@ async def create_entity(hass: HomeAssistant, **kwargs): "is_daytime": True, **kwargs, } - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs - ) + + weather_entity = mock_weather( + name="Testing", + entity_id="weather.testing", + condition=ATTR_CONDITION_SUNNY, + **kwargs, ) - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_weather_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test weather platform via config entry.""" + async_add_entities([weather_entity]) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + partial_manifest=manifest_extra, + ), + built_in=False, ) + mock_platform( + hass, + "test.weather", + MockPlatform(async_setup_entry=async_setup_entry_weather_platform), + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return entity0 + + return weather_entity diff --git a/tests/components/weather/conftest.py b/tests/components/weather/conftest.py new file mode 100644 index 00000000000..a85b5e85d4b --- /dev/null +++ b/tests/components/weather/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for Weather platform tests.""" +from collections.abc import Generator + +import pytest + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import HomeAssistant + +from tests.common import mock_config_flow, mock_platform + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index f17edb16f07..f62bed295da 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1,7 +1,5 @@ """The test for weather entity.""" -from collections.abc import Generator from datetime import datetime -from typing import Any import pytest from syrupy.assertion import SnapshotAssertion @@ -10,23 +8,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, ATTR_FORECAST_APPARENT_TEMP, - ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_DEW_POINT, ATTR_FORECAST_HUMIDITY, - ATTR_FORECAST_NATIVE_APPARENT_TEMP, - ATTR_FORECAST_NATIVE_DEW_POINT, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_UV_INDEX, - ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_APPARENT_TEMPERATURE, @@ -56,7 +44,6 @@ from homeassistant.components.weather.const import ( ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, @@ -69,9 +56,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -81,20 +66,8 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM -from . import create_entity +from . import MockWeatherTest, create_entity -from tests.common import ( - MockConfigEntry, - MockModule, - MockPlatform, - mock_config_flow, - mock_integration, - mock_platform, -) -from tests.testing_config.custom_components.test import weather as WeatherPlatform -from tests.testing_config.custom_components.test_weather import ( - weather as NewWeatherPlatform, -) from tests.typing import WebSocketGenerator @@ -134,20 +107,6 @@ class MockWeatherEntity(WeatherEntity): ] -class MockWeatherEntityPrecision(WeatherEntity): - """Mock a Weather Entity with precision.""" - - def __init__(self) -> None: - """Initiate Entity.""" - super().__init__() - self._attr_condition = ATTR_CONDITION_SUNNY - self._attr_native_temperature = 20.3 - self._attr_native_apparent_temperature = 25.3 - self._attr_native_dew_point = 2.3 - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_precision = PRECISION_HALVES - - @pytest.mark.parametrize( "native_unit", (UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS) ) @@ -160,7 +119,7 @@ class MockWeatherEntityPrecision(WeatherEntity): ) async def test_temperature( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -179,13 +138,23 @@ async def test_temperature( dew_point_state_value = TemperatureConverter.convert( dew_point_native_value, native_unit, state_unit ) - entity0 = await create_entity( - hass, - native_temperature=native_value, - native_temperature_unit=native_unit, - native_apparent_temperature=apparent_native_value, - native_dew_point=dew_point_native_value, - ) + + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": native_value, + "native_temperature_unit": native_unit, + "native_apparent_temperature": apparent_native_value, + "native_dew_point": dew_point_native_value, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast_daily = state.attributes[ATTR_FORECAST][0] @@ -229,7 +198,7 @@ async def test_temperature( ) async def test_temperature_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -243,13 +212,22 @@ async def test_temperature_no_unit( dew_point_state_value = dew_point_native_value apparent_temp_state_value = apparent_temp_native_value - entity0 = await create_entity( - hass, - native_temperature=native_value, - native_temperature_unit=native_unit, - native_dew_point=dew_point_native_value, - native_apparent_temperature=apparent_temp_native_value, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": native_value, + "native_temperature_unit": native_unit, + "native_dew_point": dew_point_native_value, + "native_apparent_temperature": apparent_temp_native_value, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -284,7 +262,7 @@ async def test_temperature_no_unit( ) async def test_pressure( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -294,9 +272,18 @@ async def test_pressure( native_value = 30 state_value = PressureConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_pressure=native_value, native_pressure_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_pressure": native_value, "native_pressure_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) + state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -314,7 +301,7 @@ async def test_pressure( ) async def test_pressure_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -324,9 +311,18 @@ async def test_pressure_no_unit( native_value = 30 state_value = native_value - entity0 = await create_entity( - hass, native_pressure=native_value, native_pressure_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_pressure": native_value, "native_pressure_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) + state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -354,7 +350,7 @@ async def test_pressure_no_unit( ) async def test_wind_speed( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -364,9 +360,17 @@ async def test_wind_speed( native_value = 10 state_value = SpeedConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_wind_speed=native_value, native_wind_speed_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_wind_speed": native_value, "native_wind_speed_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -397,7 +401,7 @@ async def test_wind_speed( ) async def test_wind_gust_speed( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -407,9 +411,20 @@ async def test_wind_gust_speed( native_value = 10 state_value = SpeedConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_wind_gust_speed=native_value, native_wind_speed_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_wind_gust_speed": native_value, + "native_wind_speed_unit": native_unit, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -433,7 +448,7 @@ async def test_wind_gust_speed( ) async def test_wind_speed_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -443,9 +458,17 @@ async def test_wind_speed_no_unit( native_value = 10 state_value = native_value - entity0 = await create_entity( - hass, native_wind_speed=native_value, native_wind_speed_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_wind_speed": native_value, "native_wind_speed_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -469,7 +492,7 @@ async def test_wind_speed_no_unit( ) async def test_visibility( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -479,9 +502,17 @@ async def test_visibility( native_value = 10 state_value = DistanceConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_visibility=native_value, native_visibility_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_visibility": native_value, "native_visibility_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) expected = state_value @@ -500,7 +531,7 @@ async def test_visibility( ) async def test_visibility_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -510,9 +541,17 @@ async def test_visibility_no_unit( native_value = 10 state_value = native_value - entity0 = await create_entity( - hass, native_visibility=native_value, native_visibility_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_visibility": native_value, "native_visibility_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) expected = state_value @@ -531,7 +570,7 @@ async def test_visibility_no_unit( ) async def test_precipitation( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -541,9 +580,20 @@ async def test_precipitation( native_value = 30 state_value = DistanceConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_precipitation=native_value, native_precipitation_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_precipitation": native_value, + "native_precipitation_unit": native_unit, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -564,7 +614,7 @@ async def test_precipitation( ) async def test_precipitation_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -574,9 +624,20 @@ async def test_precipitation_no_unit( native_value = 30 state_value = native_value - entity0 = await create_entity( - hass, native_precipitation=native_value, native_precipitation_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_precipitation": native_value, + "native_precipitation_unit": native_unit, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -589,7 +650,7 @@ async def test_precipitation_no_unit( async def test_wind_bearing_ozone_and_cloud_coverage_and_uv_index( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test wind bearing, ozone and cloud coverage.""" wind_bearing_value = 180 @@ -597,13 +658,22 @@ async def test_wind_bearing_ozone_and_cloud_coverage_and_uv_index( cloud_coverage = 75 uv_index = 1.2 - entity0 = await create_entity( - hass, - wind_bearing=wind_bearing_value, - ozone=ozone_value, - cloud_coverage=cloud_coverage, - uv_index=uv_index, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "wind_bearing": wind_bearing_value, + "ozone": ozone_value, + "cloud_coverage": cloud_coverage, + "uv_index": uv_index, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -616,12 +686,22 @@ async def test_wind_bearing_ozone_and_cloud_coverage_and_uv_index( async def test_humidity( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test humidity.""" humidity_value = 80.2 - entity0 = await create_entity(hass, humidity=humidity_value) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"humidity": humidity_value} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -631,18 +711,28 @@ async def test_humidity( async def test_none_forecast( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test that conversion with None values succeeds.""" - entity0 = await create_entity( - hass, - native_pressure=None, - native_pressure_unit=UnitOfPressure.INHG, - native_wind_speed=None, - native_wind_speed_unit=UnitOfSpeed.METERS_PER_SECOND, - native_precipitation=None, - native_precipitation_unit=UnitOfLength.MILLIMETERS, - ) + + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_pressure": None, + "native_pressure_unit": UnitOfPressure.INHG, + "native_wind_speed": None, + "native_wind_speed_unit": UnitOfSpeed.METERS_PER_SECOND, + "native_precipitation": None, + "native_precipitation_unit": UnitOfLength.MILLIMETERS, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -652,9 +742,7 @@ async def test_none_forecast( assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None -async def test_custom_units( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +async def test_custom_units(hass: HomeAssistant, config_flow_fixture: None) -> None: """Test custom unit.""" wind_speed_value = 5 wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND @@ -681,32 +769,30 @@ async def test_custom_units( entity_registry.async_update_entity_options(entry.entity_id, "weather", set_options) await hass.async_block_till_done() - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", - condition=ATTR_CONDITION_SUNNY, - native_temperature=temperature_value, - native_temperature_unit=temperature_unit, - native_wind_speed=wind_speed_value, - native_wind_speed_unit=wind_speed_unit, - native_pressure=pressure_value, - native_pressure_unit=pressure_unit, - native_visibility=visibility_value, - native_visibility_unit=visibility_unit, - native_precipitation=precipitation_value, - native_precipitation_unit=precipitation_unit, - is_daytime=True, - unique_id="very_unique", - ) - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} - ) - await hass.async_block_till_done() + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": temperature_value, + "native_temperature_unit": temperature_unit, + "native_wind_speed": wind_speed_value, + "native_wind_speed_unit": wind_speed_unit, + "native_pressure": pressure_value, + "native_pressure_unit": pressure_unit, + "native_visibility": visibility_value, + "native_visibility_unit": visibility_unit, + "native_precipitation": precipitation_value, + "native_precipitation_unit": precipitation_unit, + "is_daytime": True, + "unique_id": "very_unique", + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -802,36 +888,55 @@ async def test_attr(hass: HomeAssistant) -> None: assert weather._wind_speed_unit == UnitOfSpeed.KILOMETERS_PER_HOUR -async def test_precision_for_temperature(hass: HomeAssistant) -> None: +async def test_precision_for_temperature( + hass: HomeAssistant, + config_flow_fixture: None, +) -> None: """Test the precision for temperature.""" - weather = MockWeatherEntityPrecision() - weather.hass = hass + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" - assert weather.condition == ATTR_CONDITION_SUNNY - assert weather.native_temperature == 20.3 - assert weather.native_dew_point == 2.3 - assert weather._temperature_unit == UnitOfTemperature.CELSIUS - assert weather.precision == PRECISION_HALVES + kwargs = { + "precision": PRECISION_HALVES, + "native_temperature": 23.3, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "native_dew_point": 2.7, + } - assert weather.state_attributes[ATTR_WEATHER_TEMPERATURE] == 20.5 - assert weather.state_attributes[ATTR_WEATHER_DEW_POINT] == 2.5 + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) + + state = hass.states.get(entity0.entity_id) + + assert state.state == ATTR_CONDITION_SUNNY + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 23.5 + assert state.attributes[ATTR_WEATHER_DEW_POINT] == 2.5 + assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == UnitOfTemperature.CELSIUS async def test_forecast_twice_daily_missing_is_daytime( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test forecast_twice_daily missing mandatory attribute is_daytime.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - is_daytime=None, - supported_features=WeatherEntityFeature.FORECAST_TWICE_DAILY, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "is_daytime": None, + "supported_features": WeatherEntityFeature.FORECAST_TWICE_DAILY, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) client = await hass_ws_client(hass) @@ -867,19 +972,37 @@ async def test_forecast_twice_daily_missing_is_daytime( ) async def test_get_forecast( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, forecast_type: str, supported_features: int, snapshot: SnapshotAssertion, ) -> None: """Test get forecast service.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - supported_features=supported_features, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the forecast_twice_daily.""" + forecast = self.forecast_list[0] + forecast["is_daytime"] = True + return [forecast] + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the forecast_hourly.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": supported_features, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) response = await hass.services.async_call( DOMAIN, @@ -896,18 +1019,25 @@ async def test_get_forecast( async def test_get_forecast_no_forecast( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test get forecast service.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - supported_features=WeatherEntityFeature.FORECAST_DAILY, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return None + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": WeatherEntityFeature.FORECAST_DAILY, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) - entity0.forecast_list = None response = await hass.services.async_call( DOMAIN, SERVICE_GET_FORECAST, @@ -933,18 +1063,33 @@ async def test_get_forecast_no_forecast( ) async def test_get_forecast_unsupported( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, forecast_types: list[str], supported_features: int, ) -> None: """Test get forecast service.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - supported_features=supported_features, - ) + class MockWeatherMockForecast(MockWeatherTest): + """Mock weather class with mocked legacy forecast.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the forecast_twice_daily.""" + return self.forecast_list + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the forecast_hourly.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": supported_features, + } + weather_entity = await create_entity(hass, MockWeatherMockForecast, None, **kwargs) for forecast_type in forecast_types: with pytest.raises(HomeAssistantError): @@ -952,7 +1097,7 @@ async def test_get_forecast_unsupported( DOMAIN, SERVICE_GET_FORECAST, { - "entity_id": entity0.entity_id, + "entity_id": weather_entity.entity_id, "type": forecast_type, }, blocking=True, @@ -960,19 +1105,6 @@ async def test_get_forecast_unsupported( ) -class MockFlow(ConfigFlow): - """Test flow.""" - - -@pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: - """Mock config flow.""" - mock_platform(hass, "test.config_flow") - - with mock_config_flow("test", MockFlow): - yield - - ISSUE_TRACKER = "https://blablabla.com" @@ -1004,31 +1136,9 @@ async def test_issue_forecast_property_deprecated( ) -> None: """Test the issue is raised on deprecated forecast attributes.""" - class MockWeatherMockLegacyForecastOnly(WeatherPlatform.MockWeather): + class MockWeatherMockLegacyForecastOnly(MockWeatherTest): """Mock weather class with mocked legacy forecast.""" - def __init__(self, **values: Any) -> None: - """Initialize.""" - super().__init__(**values) - self.forecast_list: list[Forecast] | None = [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - } - ] - @property def forecast(self) -> list[Forecast] | None: """Return the forecast.""" @@ -1041,48 +1151,10 @@ async def test_issue_forecast_property_deprecated( "native_temperature": 38, "native_temperature_unit": UnitOfTemperature.CELSIUS, } - weather_entity = MockWeatherMockLegacyForecastOnly( - name="Testing", - entity_id="weather.testing", - condition=ATTR_CONDITION_SUNNY, - **kwargs, + weather_entity = await create_entity( + hass, MockWeatherMockLegacyForecastOnly, manifest_extra, **kwargs ) - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_weather_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test weather platform via config entry.""" - async_add_entities([weather_entity]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - partial_manifest=manifest_extra, - ), - built_in=False, - ) - mock_platform( - hass, - "test.weather", - MockPlatform(async_setup_entry=async_setup_entry_weather_platform), - ) - - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert weather_entity.state == ATTR_CONDITION_SUNNY issues = ir.async_get(hass) @@ -1105,37 +1177,37 @@ async def test_issue_forecast_property_deprecated( async def test_issue_forecast_attr_deprecated( hass: HomeAssistant, - enable_custom_integrations: None, + issue_registry: ir.IssueRegistry, + config_flow_fixture: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test the issue is raised on deprecated forecast attributes.""" + class MockWeatherMockLegacyForecast(MockWeatherTest): + """Mock weather class with legacy forecast.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + kwargs = { "native_temperature": 38, "native_temperature_unit": UnitOfTemperature.CELSIUS, } - platform: WeatherPlatform = getattr(hass.components, "test.weather") - caplog.clear() - platform.init(empty=True) - weather = platform.MockWeather( - name="Testing", - entity_id="weather.testing", - condition=ATTR_CONDITION_SUNNY, - **kwargs, + + # Fake that the class belongs to a custom integration + MockWeatherMockLegacyForecast.__module__ = "custom_components.test.weather" + + weather_entity = await create_entity( + hass, MockWeatherMockLegacyForecast, None, **kwargs ) - weather._attr_forecast = [] - platform.ENTITIES.append(weather) - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test", "name": "testing"}} + assert weather_entity.state == ATTR_CONDITION_SUNNY + + issue = issue_registry.async_get_issue( + "weather", "deprecated_weather_forecast_test" ) - await hass.async_block_till_done() - - assert entity0.state == ATTR_CONDITION_SUNNY - - issues = ir.async_get(hass) - issue = issues.async_get_issue("weather", "deprecated_weather_forecast_test") assert issue assert issue.issue_domain == "test" assert issue.issue_id == "deprecated_weather_forecast_test" @@ -1143,7 +1215,7 @@ async def test_issue_forecast_attr_deprecated( assert issue.translation_placeholders == {"platform": "test"} assert ( - "test::MockWeather implements the `forecast` property or " + "test::MockWeatherMockLegacyForecast implements the `forecast` property or " "sets `self._attr_forecast` in a subclass of WeatherEntity, this is deprecated " "and will be unsupported from Home Assistant 2024.3. Please report it to the " "author of the 'test' custom integration" @@ -1152,36 +1224,33 @@ async def test_issue_forecast_attr_deprecated( async def test_issue_forecast_deprecated_no_logging( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test the no issue is raised on deprecated forecast attributes if new methods exist.""" + class MockWeatherMockForecast(MockWeatherTest): + """Mock weather class with mocked new method and legacy forecast.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + kwargs = { "native_temperature": 38, "native_temperature_unit": UnitOfTemperature.CELSIUS, } - platform: NewWeatherPlatform = getattr(hass.components, "test_weather.weather") - caplog.clear() - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", - entity_id="weather.test", - condition=ATTR_CONDITION_SUNNY, - **kwargs, - ) - ) - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test_weather", "name": "test"}} - ) - await hass.async_block_till_done() + weather_entity = await create_entity(hass, MockWeatherMockForecast, None, **kwargs) - assert entity0.state == ATTR_CONDITION_SUNNY + assert weather_entity.state == ATTR_CONDITION_SUNNY - assert "Setting up weather.test_weather" in caplog.text + assert "Setting up weather.test" in caplog.text assert ( "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" not in caplog.text diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 049a38cac1e..6b2ce4b633a 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -5,56 +5,43 @@ from datetime import timedelta from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.components.weather import ATTR_CONDITION_SUNNY, ATTR_FORECAST +from homeassistant.components.weather import ATTR_FORECAST, Forecast from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM +from . import MockWeatherTest, create_entity + from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -from tests.testing_config.custom_components.test import weather as WeatherPlatform - - -async def create_entity(hass: HomeAssistant, **kwargs): - """Create the weather entity to run tests on.""" - kwargs = { - "native_temperature": None, - "native_temperature_unit": None, - "is_daytime": True, - **kwargs, - } - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs - ) - ) - - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} - ) - await hass.async_block_till_done() - return entity0 async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None + recorder_mock: Recorder, + hass: HomeAssistant, + config_flow_fixture: None, ) -> None: """Test weather attributes to be excluded.""" now = dt_util.utcnow() - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - ) + + class MockWeatherMockForecast(MockWeatherTest): + """Mock weather class with mocked legacy forecast.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + weather_entity = await create_entity(hass, MockWeatherMockForecast, None, **kwargs) hass.config.units = METRIC_SYSTEM await hass.async_block_till_done() - state = hass.states.get(entity0.entity_id) + state = hass.states.get(weather_entity.entity_id) assert state.attributes[ATTR_FORECAST] await hass.async_block_till_done() diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py index 4f5223c6f79..4a401d79849 100644 --- a/tests/components/weather/test_websocket_api.py +++ b/tests/components/weather/test_websocket_api.py @@ -1,11 +1,11 @@ """Test the weather websocket API.""" -from homeassistant.components.weather import WeatherEntityFeature +from homeassistant.components.weather import Forecast, WeatherEntityFeature from homeassistant.components.weather.const import DOMAIN from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import create_entity +from . import MockWeatherTest, create_entity from tests.typing import WebSocketGenerator @@ -40,16 +40,23 @@ async def test_device_class_units( async def test_subscribe_forecast( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test multiple forecast.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - supported_features=WeatherEntityFeature.FORECAST_DAILY, - ) + class MockWeatherMockForecast(MockWeatherTest): + """Mock weather class.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": WeatherEntityFeature.FORECAST_DAILY, + } + weather_entity = await create_entity(hass, MockWeatherMockForecast, None, **kwargs) client = await hass_ws_client(hass) @@ -57,7 +64,7 @@ async def test_subscribe_forecast( { "type": "weather/subscribe_forecast", "forecast_type": "daily", - "entity_id": entity0.entity_id, + "entity_id": weather_entity.entity_id, } ) msg = await client.receive_json() @@ -82,16 +89,16 @@ async def test_subscribe_forecast( ], } - await entity0.async_update_listeners(None) + await weather_entity.async_update_listeners(None) msg = await client.receive_json() assert msg["event"] == forecast - await entity0.async_update_listeners(["daily"]) + await weather_entity.async_update_listeners(["daily"]) msg = await client.receive_json() assert msg["event"] == forecast - entity0.forecast_list = None - await entity0.async_update_listeners(None) + weather_entity.forecast_list = None + await weather_entity.async_update_listeners(None) msg = await client.receive_json() assert msg["event"] == {"type": "daily", "forecast": None} @@ -99,7 +106,6 @@ async def test_subscribe_forecast( async def test_subscribe_forecast_unknown_entity( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, ) -> None: """Test multiple forecast.""" @@ -125,23 +131,25 @@ async def test_subscribe_forecast_unknown_entity( async def test_subscribe_forecast_unsupported( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test multiple forecast.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + weather_entity = await create_entity(hass, MockWeatherMock, None, **kwargs) client = await hass_ws_client(hass) await client.send_json_auto_id( { "type": "weather/subscribe_forecast", "forecast_type": "daily", - "entity_id": entity0.entity_id, + "entity_id": weather_entity.entity_id, } ) msg = await client.receive_json() diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 633a5e4c389..5afb6001b8a 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -23,6 +23,7 @@ from homeassistant.components.weather import ( Forecast, WeatherEntity, ) +from homeassistant.core import HomeAssistant from tests.common import MockEntity @@ -36,7 +37,7 @@ def init(empty=False): async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None + hass: HomeAssistant, config, async_add_entities_callback, discovery_info=None ): """Return mock entities.""" async_add_entities_callback(ENTITIES) @@ -135,79 +136,10 @@ class MockWeather(MockEntity, WeatherEntity): """Return the current condition.""" return self._handle("condition") - -class MockWeatherCompat(MockEntity, WeatherEntity): - """Mock weather class for backwards compatibility check.""" - @property - def temperature(self) -> float | None: - """Return the platform temperature.""" - return self._handle("temperature") - - @property - def temperature_unit(self) -> str | None: - """Return the unit of measurement for temperature.""" - return self._handle("temperature_unit") - - @property - def pressure(self) -> float | None: - """Return the pressure.""" - return self._handle("pressure") - - @property - def pressure_unit(self) -> str | None: - """Return the unit of measurement for pressure.""" - return self._handle("pressure_unit") - - @property - def humidity(self) -> float | None: - """Return the humidity.""" - return self._handle("humidity") - - @property - def wind_speed(self) -> float | None: - """Return the wind speed.""" - return self._handle("wind_speed") - - @property - def wind_speed_unit(self) -> str | None: - """Return the unit of measurement for wind speed.""" - return self._handle("wind_speed_unit") - - @property - def wind_bearing(self) -> float | str | None: - """Return the wind bearing.""" - return self._handle("wind_bearing") - - @property - def ozone(self) -> float | None: - """Return the ozone level.""" - return self._handle("ozone") - - @property - def visibility(self) -> float | None: - """Return the visibility.""" - return self._handle("visibility") - - @property - def visibility_unit(self) -> str | None: - """Return the unit of measurement for visibility.""" - return self._handle("visibility_unit") - - @property - def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" - return self._handle("forecast") - - @property - def precipitation_unit(self) -> str | None: - """Return the unit of measurement for accumulated precipitation.""" - return self._handle("precipitation_unit") - - @property - def condition(self) -> str | None: - """Return the current condition.""" - return self._handle("condition") + def precision(self) -> float: + """Return the precision of the temperature.""" + return self._handle("precision") class MockWeatherMockForecast(MockWeather): diff --git a/tests/testing_config/custom_components/test_weather/__init__.py b/tests/testing_config/custom_components/test_weather/__init__.py deleted file mode 100644 index ddec081ed8b..00000000000 --- a/tests/testing_config/custom_components/test_weather/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""An integration with Weather platform.""" diff --git a/tests/testing_config/custom_components/test_weather/manifest.json b/tests/testing_config/custom_components/test_weather/manifest.json deleted file mode 100644 index d1238659b41..00000000000 --- a/tests/testing_config/custom_components/test_weather/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "test_weather", - "name": "Test Weather", - "documentation": "http://example.com", - "requirements": [], - "dependencies": [], - "codeowners": [], - "version": "1.2.3" -} diff --git a/tests/testing_config/custom_components/test_weather/weather.py b/tests/testing_config/custom_components/test_weather/weather.py deleted file mode 100644 index 68d9ccab712..00000000000 --- a/tests/testing_config/custom_components/test_weather/weather.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Provide a mock weather platform. - -Call init before using it in your tests to ensure clean test data. -""" -from __future__ import annotations - -from typing import Any - -from homeassistant.components.weather import ( - ATTR_FORECAST_CLOUD_COVERAGE, - ATTR_FORECAST_HUMIDITY, - ATTR_FORECAST_IS_DAYTIME, - ATTR_FORECAST_NATIVE_APPARENT_TEMP, - ATTR_FORECAST_NATIVE_DEW_POINT, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_UV_INDEX, - ATTR_FORECAST_WIND_BEARING, - Forecast, - WeatherEntity, -) - -from tests.common import MockEntity - -ENTITIES = [] - - -def init(empty=False): - """Initialize the platform with entities.""" - global ENTITIES - ENTITIES = [] if empty else [MockWeatherMockForecast()] - - -async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): - """Return mock entities.""" - async_add_entities_callback(ENTITIES) - - -class MockWeatherMockForecast(MockEntity, WeatherEntity): - """Mock weather class.""" - - def __init__(self, **values: Any) -> None: - """Initialize.""" - super().__init__(**values) - self.forecast_list: list[Forecast] | None = [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - } - ] - - @property - def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" - return self.forecast_list - - async def async_forecast_daily(self) -> list[Forecast] | None: - """Return the forecast_daily.""" - return self.forecast_list - - async def async_forecast_twice_daily(self) -> list[Forecast] | None: - """Return the forecast_twice_daily.""" - return [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - ATTR_FORECAST_IS_DAYTIME: self._values.get("is_daytime"), - } - ] - - async def async_forecast_hourly(self) -> list[Forecast] | None: - """Return the forecast_hourly.""" - return [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - } - ] - - @property - def native_temperature(self) -> float | None: - """Return the platform temperature.""" - return self._handle("native_temperature") - - @property - def native_apparent_temperature(self) -> float | None: - """Return the platform apparent temperature.""" - return self._handle("native_apparent_temperature") - - @property - def native_dew_point(self) -> float | None: - """Return the platform dewpoint temperature.""" - return self._handle("native_dew_point") - - @property - def native_temperature_unit(self) -> str | None: - """Return the unit of measurement for temperature.""" - return self._handle("native_temperature_unit") - - @property - def native_pressure(self) -> float | None: - """Return the pressure.""" - return self._handle("native_pressure") - - @property - def native_pressure_unit(self) -> str | None: - """Return the unit of measurement for pressure.""" - return self._handle("native_pressure_unit") - - @property - def humidity(self) -> float | None: - """Return the humidity.""" - return self._handle("humidity") - - @property - def native_wind_gust_speed(self) -> float | None: - """Return the wind speed.""" - return self._handle("native_wind_gust_speed") - - @property - def native_wind_speed(self) -> float | None: - """Return the wind speed.""" - return self._handle("native_wind_speed") - - @property - def native_wind_speed_unit(self) -> str | None: - """Return the unit of measurement for wind speed.""" - return self._handle("native_wind_speed_unit") - - @property - def wind_bearing(self) -> float | str | None: - """Return the wind bearing.""" - return self._handle("wind_bearing") - - @property - def ozone(self) -> float | None: - """Return the ozone level.""" - return self._handle("ozone") - - @property - def cloud_coverage(self) -> float | None: - """Return the cloud coverage in %.""" - return self._handle("cloud_coverage") - - @property - def uv_index(self) -> float | None: - """Return the UV index.""" - return self._handle("uv_index") - - @property - def native_visibility(self) -> float | None: - """Return the visibility.""" - return self._handle("native_visibility") - - @property - def native_visibility_unit(self) -> str | None: - """Return the unit of measurement for visibility.""" - return self._handle("native_visibility_unit") - - @property - def native_precipitation_unit(self) -> str | None: - """Return the native unit of measurement for accumulated precipitation.""" - return self._handle("native_precipitation_unit") - - @property - def condition(self) -> str | None: - """Return the current condition.""" - return self._handle("condition") From a63c4208903ca4dad173fe5bef7246135d980421 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Nov 2023 06:04:07 +0100 Subject: [PATCH 173/982] Quote entity ids in entity excpetions (#103286) --- homeassistant/helpers/entity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 1bc8f0b308b..207016aee86 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -989,7 +989,7 @@ class Entity(ABC): """Start adding an entity to a platform.""" if self._platform_state == EntityPlatformState.ADDED: raise HomeAssistantError( - f"Entity {self.entity_id} cannot be added a second time to an entity" + f"Entity '{self.entity_id}' cannot be added a second time to an entity" " platform" ) @@ -1036,7 +1036,7 @@ class Entity(ABC): # EntityComponent and can be removed in HA Core 2024.1 if self.platform and self._platform_state != EntityPlatformState.ADDED: raise HomeAssistantError( - f"Entity {self.entity_id} async_remove called twice" + f"Entity '{self.entity_id}' async_remove called twice" ) self._platform_state = EntityPlatformState.REMOVED @@ -1099,7 +1099,7 @@ class Entity(ABC): # This is an assert as it should never happen, but helps in tests assert ( not self.registry_entry.disabled_by - ), f"Entity {self.entity_id} is being added while it's disabled" + ), f"Entity '{self.entity_id}' is being added while it's disabled" self.async_on_remove( async_track_entity_registry_updated_event( From c81ada16ba1c1c30d4b6b9de17cfc4216b1b73a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Nov 2023 01:35:51 -0500 Subject: [PATCH 174/982] Add debug logging for which adapter is used to connect bluetooth devices (#103264) Log which adapter is used to connect bluetooth devices This is a debug logging improvement to help users find problems with their setup --- homeassistant/components/bluetooth/wrappers.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 97f253f8825..bfcee9d25df 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -270,6 +270,8 @@ class HaBleakClientWrapper(BleakClient): """Connect to the specified GATT server.""" assert models.MANAGER is not None manager = models.MANAGER + if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("%s: Looking for backend to connect", self.__address) wrapped_backend = self._async_get_best_available_backend_and_device(manager) device = wrapped_backend.device scanner = wrapped_backend.scanner @@ -281,12 +283,14 @@ class HaBleakClientWrapper(BleakClient): timeout=self.__timeout, hass=manager.hass, ) - if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): + if debug_logging: # Only lookup the description if we are going to log it description = ble_device_description(device) _, adv = scanner.discovered_devices_and_advertisement_data[device.address] rssi = adv.rssi - _LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi) + _LOGGER.debug( + "%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi + ) connected = None try: connected = await super().connect(**kwargs) @@ -301,7 +305,9 @@ class HaBleakClientWrapper(BleakClient): manager.async_release_connection_slot(device) if debug_logging: - _LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi) + _LOGGER.debug( + "%s: Connected via %s (last rssi: %s)", description, scanner.name, rssi + ) return connected @hass_callback From f5cc4dcf3e83cf2d4c472b47ffd2ca85bb6fbb66 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 3 Nov 2023 08:34:49 +0100 Subject: [PATCH 175/982] Add MAC adress to devolo Home Network DeviceInfo (#103290) Add MAC adress to devolo Home Network devices --- homeassistant/components/devolo_home_network/entity.py | 3 ++- tests/components/devolo_home_network/snapshots/test_init.ambr | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 53c502dc811..a0aa0466d90 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import ( from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -49,6 +49,7 @@ class DevoloEntity(Entity): self._attr_device_info = DeviceInfo( configuration_url=f"http://{device.ip}", + connections={(CONNECTION_NETWORK_MAC, device.mac)}, identifiers={(DOMAIN, str(device.serial_number))}, manufacturer="devolo", model=device.product, diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index f2c27183945..6ba4292c5de 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -5,6 +5,10 @@ 'config_entries': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), }), 'disabled_by': None, 'entry_type': None, From 89a9e6c6e842e7cf2265238d57ca011ed44d225e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 3 Nov 2023 09:11:49 +0100 Subject: [PATCH 176/982] Add trigger selector for blueprint (#103050) --- homeassistant/helpers/selector.py | 23 ++++++++++++++++++++++- tests/helpers/test_selector.py | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 51a54b3988f..ac5166911ff 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -459,7 +459,7 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): class ConditionSelectorConfig(TypedDict): - """Class to represent an action selector config.""" + """Class to represent an condition selector config.""" @SELECTORS.register("condition") @@ -1280,6 +1280,27 @@ class TimeSelector(Selector[TimeSelectorConfig]): return cast(str, data) +class TriggerSelectorConfig(TypedDict): + """Class to represent an trigger selector config.""" + + +@SELECTORS.register("trigger") +class TriggerSelector(Selector[TriggerSelectorConfig]): + """Selector of a trigger sequence (script syntax).""" + + selector_type = "trigger" + + CONFIG_SCHEMA = vol.Schema({}) + + def __init__(self, config: TriggerSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + return vol.Schema(cv.TRIGGER_SCHEMA)(data) + + class FileSelectorConfig(TypedDict): """Class to represent a file selector config.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index ee4749be346..1e449fd103a 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1074,3 +1074,27 @@ def test_condition_selector_schema( ) -> None: """Test condition sequence selector.""" _test_selector("condition", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {}, + ( + [ + { + "platform": "numeric_state", + "entity_id": ["sensor.temperature"], + "below": 20, + } + ], + [], + ), + ("abc"), + ), + ), +) +def test_trigger_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test trigger sequence selector.""" + _test_selector("trigger", schema, valid_selections, invalid_selections) From ac1dc4eeeafadb1bc71c7ff97f7c2c6390c36d06 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 3 Nov 2023 07:08:40 -0400 Subject: [PATCH 177/982] Fix firmware update failure (#103277) --- homeassistant/components/zwave_js/update.py | 22 +++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 6efae29e46e..e49eb8a2017 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import Counter from collections.abc import Callable -from dataclasses import asdict, dataclass +from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Final @@ -54,7 +54,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" return { - ATTR_LATEST_VERSION_FIRMWARE: asdict(self.latest_version_firmware) + ATTR_LATEST_VERSION_FIRMWARE: self.latest_version_firmware.to_dict() if self.latest_version_firmware else None } @@ -339,19 +339,25 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): and (latest_version := state.attributes.get(ATTR_LATEST_VERSION)) is not None and (extra_data := await self.async_get_last_extra_data()) - ): - self._attr_latest_version = latest_version - self._latest_version_firmware = ( - ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + and ( + latest_version_firmware := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) - # If we have no state or latest version to restore, we can set the latest + ): + self._attr_latest_version = latest_version + self._latest_version_firmware = latest_version_firmware + # If we have no state or latest version to restore, or the latest version is + # the same as the installed version, we can set the latest # version to installed so that the entity starts as off. If we have partial # restore data due to an upgrade to an HA version where this feature is released # from one that is not the entity will start in an unknown state until we can # correct on next update - elif not state or not latest_version: + elif ( + not state + or not latest_version + or latest_version == self._attr_installed_version + ): self._attr_latest_version = self._attr_installed_version # Spread updates out in 5 minute increments to avoid flooding the network From 680162d494cf70b59d934b98a1f4a2e74535c112 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 3 Nov 2023 12:09:31 +0100 Subject: [PATCH 178/982] Fix Matter 1.2 locks with specific unlatch/unbolt support (#103275) --- homeassistant/components/matter/lock.py | 58 +- tests/components/matter/conftest.py | 10 + .../fixtures/nodes/door-lock-with-unbolt.json | 510 ++++++++++++++++++ tests/components/matter/test_door_lock.py | 49 ++ 4 files changed, 605 insertions(+), 22 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index a5f625f9e73..8491f58e387 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -1,12 +1,15 @@ """Matter lock.""" from __future__ import annotations -from enum import IntFlag from typing import Any from chip.clusters import Objects as clusters -from homeassistant.components.lock import LockEntity, LockEntityDescription +from homeassistant.components.lock import ( + LockEntity, + LockEntityDescription, + LockEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE, Platform from homeassistant.core import HomeAssistant, callback @@ -17,6 +20,8 @@ from .entity import MatterEntity from .helpers import get_matter from .models import MatterDiscoverySchema +DoorLockFeature = clusters.DoorLock.Bitmaps.Feature + async def async_setup_entry( hass: HomeAssistant, @@ -61,6 +66,14 @@ class MatterLock(MatterEntity, LockEntity): return bool(self.features & DoorLockFeature.kDoorPositionSensor) + @property + def supports_unbolt(self) -> bool: + """Return True if the lock supports unbolt.""" + if self.features is None: + return False + + return bool(self.features & DoorLockFeature.kUnbolt) + async def send_device_command( self, command: clusters.ClusterCommand, @@ -92,6 +105,25 @@ class MatterLock(MatterEntity, LockEntity): self._lock_option_default_code, ) code_bytes = code.encode() if code else None + if self.supports_unbolt: + # if the lock reports it has separate unbolt support, + # the unlock command should unbolt only on the unlock command + # and unlatch on the HA 'open' command. + await self.send_device_command( + command=clusters.DoorLock.Commands.UnboltDoor(code_bytes) + ) + else: + await self.send_device_command( + command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) + ) + + async def async_open(self, **kwargs: Any) -> None: + """Open the door latch.""" + code: str = kwargs.get( + ATTR_CODE, + self._lock_option_default_code, + ) + code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) ) @@ -104,6 +136,8 @@ class MatterLock(MatterEntity, LockEntity): self.features = int( self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap) ) + if self.supports_unbolt: + self._attr_supported_features = LockEntityFeature.OPEN lock_state = self.get_matter_attribute_value( clusters.DoorLock.Attributes.LockState @@ -144,26 +178,6 @@ class MatterLock(MatterEntity, LockEntity): ) -class DoorLockFeature(IntFlag): - """Temp enum that represents the features of a door lock. - - Should be replaced by the library provided one once that is released. - """ - - kPinCredential = 0x1 # noqa: N815 - kRfidCredential = 0x2 # noqa: N815 - kFingerCredentials = 0x4 # noqa: N815 - kLogging = 0x8 # noqa: N815 - kWeekDayAccessSchedules = 0x10 # noqa: N815 - kDoorPositionSensor = 0x20 # noqa: N815 - kFaceCredentials = 0x40 # noqa: N815 - kCredentialsOverTheAirAccess = 0x80 # noqa: N815 - kUser = 0x100 # noqa: N815 - kNotification = 0x200 # noqa: N815 - kYearDayAccessSchedules = 0x400 # noqa: N815 - kHolidaySchedules = 0x800 # noqa: N815 - - DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LOCK, diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 6a14148585a..03443e4c4b9 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -223,6 +223,16 @@ async def door_lock_fixture( return await setup_integration_with_node_fixture(hass, "door-lock", matter_client) +@pytest.fixture(name="door_lock_with_unbolt") +async def door_lock_with_unbolt_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a door lock node with unbolt feature.""" + return await setup_integration_with_node_fixture( + hass, "door-lock-with-unbolt", matter_client + ) + + @pytest.fixture(name="eve_contact_sensor_node") async def eve_contact_sensor_node_fixture( hass: HomeAssistant, matter_client: MagicMock diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json new file mode 100644 index 00000000000..6cbd75ab09c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json @@ -0,0 +1,510 @@ +{ + "node_id": 1, + "date_commissioned": "2023-03-07T09:06:06.059454", + "last_interview": "2023-03-07T09:06:06.059456", + "interview_version": 2, + "available": true, + "attributes": { + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 60, 62, + 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Door Lock", + "0/40/4": 32769, + "0/40/5": "Mock Door Lock", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-door-lock", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 65535 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/46/0": [0, 1], + "0/46/65532": 0, + "0/46/65533": 1, + "0/46/65528": [], + "0/46/65529": [], + "0/46/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "0/47/0": 1, + "0/47/1": 0, + "0/47/2": "USB", + "0/47/6": 0, + "0/47/65532": 1, + "0/47/65533": 1, + "0/47/65528": [], + "0/47/65529": [], + "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "eth0", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "/mQDt/2Q", + "IPv4Addresses": ["CjwBaQ=="], + "IPv6Addresses": [ + "/VqgxiAxQib8ZAP//rf9kA==", + "IAEEcLs7AAb8ZAP//rf9kA==", + "/oAAAAAAAAD8ZAP//rf9kA==" + ], + "type": 2 + }, + { + "name": "lo", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "AAAAAAAA", + "IPv4Addresses": ["fwAAAQ=="], + "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "type": 0 + } + ], + "0/51/1": 1, + "0/51/2": 25, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "id": 26957, + "name": "26957", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26956, + "name": "26956", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26955, + "name": "26955", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26953, + "name": "26953", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26952, + "name": "26952", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + } + ], + "0/52/1": 351120, + "0/52/2": 529520, + "0/52/3": 529520, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/53/0": null, + "0/53/1": null, + "0/53/2": null, + "0/53/3": null, + "0/53/4": null, + "0/53/5": null, + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": null, + "0/53/10": null, + "0/53/11": null, + "0/53/12": null, + "0/53/13": null, + "0/53/14": 0, + "0/53/15": 0, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 0, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 0, + "0/53/23": 0, + "0/53/24": 0, + "0/53/25": 0, + "0/53/26": 0, + "0/53/27": 0, + "0/53/28": 0, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 0, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 0, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 0, + "0/53/40": 0, + "0/53/41": 0, + "0/53/42": 0, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 0, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": null, + "0/53/57": null, + "0/53/58": null, + "0/53/59": null, + "0/53/60": null, + "0/53/61": null, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65530, 65531, + 65532, 65533 + ], + "0/55/0": null, + "0/55/1": false, + "0/55/2": 823, + "0/55/3": 969, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 25, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "fabricIndex": 1 + } + ], + "0/62/1": [ + { + "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEn+MvYx0Ot2r1NqEpJrlz90FzXro1EJTRb2XuEraRy5W033XkMuqJL5EAnaI8S9YrkVrBkcX9B0otmZORke53FjcKNQEpARgkAmAwBBTNCOnzxc3zr9iFm19YbsW93ltH6jAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQILjpR3BTSHHl6DQtvwzWkjmA+i5jjXdc3qjemFGFjFVAnV6dPLQo7tctC8Y0uL4ZNERga2/NZAt1gRD72S0YR4Y" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65530, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 0, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "deviceType": 10, + "revision": 1 + } + ], + "1/29/1": [3, 6, 29, 47, 257], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 1, + "1/47/2": "Battery", + "1/47/14": 0, + "1/47/15": false, + "1/47/16": 0, + "1/47/19": "", + "1/47/65532": 10, + "1/47/65533": 1, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [ + 0, 1, 2, 14, 15, 16, 19, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/257/0": 1, + "1/257/1": 0, + "1/257/2": true, + "1/257/3": 1, + "1/257/17": 10, + "1/257/18": 10, + "1/257/19": 10, + "1/257/20": 10, + "1/257/21": 10, + "1/257/22": 10, + "1/257/23": 8, + "1/257/24": 6, + "1/257/25": 20, + "1/257/26": 10, + "1/257/27": 1, + "1/257/28": 5, + "1/257/33": "en", + "1/257/35": 60, + "1/257/36": 0, + "1/257/37": 0, + "1/257/38": 65526, + "1/257/41": false, + "1/257/43": false, + "1/257/48": 3, + "1/257/49": 10, + "1/257/51": false, + "1/257/65532": 7603, + "1/257/65533": 6, + "1/257/65528": [12, 15, 18, 28, 35, 37], + "1/257/65529": [ + 0, 1, 3, 11, 12, 13, 14, 15, 16, 17, 18, 19, 26, 27, 29, 34, 36, 38 + ], + "1/257/65531": [ + 0, 1, 2, 3, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 33, 35, 36, + 37, 38, 41, 43, 48, 49, 51, 65528, 65529, 65530, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index 221ae891d67..a9753824edc 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -10,6 +10,7 @@ from homeassistant.components.lock import ( STATE_LOCKING, STATE_UNLOCKED, STATE_UNLOCKING, + LockEntityFeature, ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -135,3 +136,51 @@ async def test_lock_requires_pin( command=clusters.DoorLock.Commands.LockDoor(code.encode()), timed_request_timeout_ms=1000, ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_lock_with_unbolt( + hass: HomeAssistant, + matter_client: MagicMock, + door_lock_with_unbolt: MatterNode, +) -> None: + """Test door lock.""" + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_LOCKED + assert state.attributes["supported_features"] & LockEntityFeature.OPEN + # test unlock/unbolt + await hass.services.async_call( + "lock", + "unlock", + { + "entity_id": "lock.mock_door_lock", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + # unlock should unbolt on a lock with unbolt feature + assert matter_client.send_device_command.call_args == call( + node_id=door_lock_with_unbolt.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.UnboltDoor(), + timed_request_timeout_ms=1000, + ) + matter_client.send_device_command.reset_mock() + # test open / unlatch + await hass.services.async_call( + "lock", + "open", + { + "entity_id": "lock.mock_door_lock", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock_with_unbolt.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.UnlockDoor(), + timed_request_timeout_ms=1000, + ) From 5d2110c32c36a361b74d30e73d3236c56c8f62a4 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 3 Nov 2023 04:15:49 -0700 Subject: [PATCH 179/982] Bump opower to 0.0.39 (#103292) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index a27d6f6f680..1022ab07e2c 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.38"] + "requirements": ["opower==0.0.39"] } diff --git a/requirements_all.txt b/requirements_all.txt index 99d4ceb1ac0..7eb491b98df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1397,7 +1397,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.38 +opower==0.0.39 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 341d4b3f735..e1fdd20ad9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1075,7 +1075,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.38 +opower==0.0.39 # homeassistant.components.oralb oralb-ble==0.17.6 From 2a31eb676239fdf4c588eef37a4720028b61f67d Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Nov 2023 12:17:36 +0100 Subject: [PATCH 180/982] Fix Plugwise Schedule selection (#103262) --- homeassistant/components/plugwise/select.py | 2 +- tests/components/plugwise/test_select.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 6646cce3369..138e5fe3b59 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -40,7 +40,7 @@ SELECT_TYPES = ( key="select_schedule", translation_key="select_schedule", icon="mdi:calendar-clock", - command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON), + command=lambda api, loc, opt: api.set_schedule_state(loc, STATE_ON, opt), options_key="available_schedules", ), PlugwiseSelectEntityDescription( diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 7ec5559a608..9df20a5ffc8 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -40,5 +40,7 @@ async def test_adam_change_select_entity( assert mock_smile_adam.set_schedule_state.call_count == 1 mock_smile_adam.set_schedule_state.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", "Badkamer Schema", "on" + "c50f167537524366a5af7aa3942feb1e", + "on", + "Badkamer Schema", ) From eadfd51dab16ab7cbf1e889f7dce671db227a4e7 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 3 Nov 2023 13:11:26 +0100 Subject: [PATCH 181/982] Add loggers to the duotecno integration (#103300) --- homeassistant/components/duotecno/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index f6482791292..60f59e865df 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", + "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", "requirements": ["pyDuotecno==2023.10.1"] } From eeb88f5e07d987e45e0404bb860f1ce48bd56880 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 3 Nov 2023 10:36:48 -0400 Subject: [PATCH 182/982] Fix zwave_js cover bug for Window Covering CC values (#103289) * Fix cover bug for Window Covering CC values * update test * Fix fixture * Remove no-op line from test --- homeassistant/components/zwave_js/cover.py | 3 +- .../convert_device_diagnostics_to_fixture.py | 10 +- .../fixtures/cover_iblinds_v3_state.json | 708 ++++++++---------- .../zwave_js/fixtures/zooz_zse44_state.json | 268 +++---- ...t_convert_device_diagnostics_to_fixture.py | 5 +- tests/components/zwave_js/test_cover.py | 12 +- 6 files changed, 459 insertions(+), 547 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 9a8cb203c05..364eafd8caf 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -18,7 +18,6 @@ from zwave_js_server.const.command_class.multilevel_switch import ( from zwave_js_server.const.command_class.window_covering import ( NO_POSITION_PROPERTY_KEYS, NO_POSITION_SUFFIX, - WINDOW_COVERING_OPEN_PROPERTY, SlatStates, ) from zwave_js_server.model.driver import Driver @@ -370,7 +369,7 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): set_values_func( value, stop_value=self.get_zwave_value( - WINDOW_COVERING_OPEN_PROPERTY, + "levelChangeUp", value_property_key=value.property_key, ), ) diff --git a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py index 1e8d295227f..826f3eebe0c 100644 --- a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py +++ b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py @@ -60,10 +60,12 @@ def extract_fixture_data(diagnostics_data: Any) -> dict: ): raise ValueError("Invalid diagnostics file format") state: dict = diagnostics_data["data"]["state"] - if isinstance(state["values"], list): - return state - values_dict: dict[str, dict] = state.pop("values") - state["values"] = list(values_dict.values()) + if not isinstance(state["values"], list): + values_dict: dict[str, dict] = state.pop("values") + state["values"] = list(values_dict.values()) + if not isinstance(state["endpoints"], list): + endpoints_dict: dict[str, dict] = state.pop("endpoints") + state["endpoints"] = list(endpoints_dict.values()) return state diff --git a/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json b/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json index f0da41e4b6f..cf04e885d79 100644 --- a/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json +++ b/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json @@ -1,5 +1,5 @@ { - "nodeId": 12, + "nodeId": 131, "index": 0, "installerIcon": 6656, "userIcon": 6656, @@ -7,12 +7,13 @@ "ready": true, "isListening": false, "isRouting": true, - "isSecure": true, + "isSecure": false, "manufacturerId": 647, "productId": 114, "productType": 4, "firmwareVersion": "3.12.1", "zwavePlusVersion": 2, + "name": "Blind West Bed 1", "deviceConfig": { "filename": "/data/db/devices/0x0287/iblindsv3.json", "isEmbedded": true, @@ -38,321 +39,61 @@ "associations": {}, "paramInformation": { "_map": {} + }, + "compat": { + "removeCCs": {} } }, "label": "iblinds V3", "interviewAttempts": 1, - "endpoints": [ - { - "nodeId": 12, - "index": 0, - "installerIcon": 6656, - "userIcon": 6656, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 17, - "label": "Multilevel Switch" - }, - "specific": { - "key": 7, - "label": "Motor Control Class C" - }, - "mandatorySupportedCCs": [32, 38, 37, 114, 134], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 38, - "name": "Multilevel Switch", - "version": 4, - "isSecure": true - }, - { - "id": 37, - "name": "Binary Switch", - "version": 2, - "isSecure": true - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 2, - "isSecure": true - }, - { - "id": 134, - "name": "Version", - "version": 3, - "isSecure": true - }, - { - "id": 94, - "name": "Z-Wave Plus Info", - "version": 2, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 2, - "isSecure": true - }, - { - "id": 89, - "name": "Association Group Information", - "version": 3, - "isSecure": true - }, - { - "id": 85, - "name": "Transport Service", - "version": 2, - "isSecure": false - }, - { - "id": 90, - "name": "Device Reset Locally", - "version": 1, - "isSecure": true - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": true - }, - { - "id": 159, - "name": "Security 2", - "version": 1, - "isSecure": true - }, - { - "id": 108, - "name": "Supervision", - "version": 1, - "isSecure": false - }, - { - "id": 122, - "name": "Firmware Update Meta Data", - "version": 5, - "isSecure": true - }, - { - "id": 128, - "name": "Battery", - "version": 1, - "isSecure": true - }, - { - "id": 112, - "name": "Configuration", - "version": 4, - "isSecure": true - }, - { - "id": 135, - "name": "Indicator", - "version": 3, - "isSecure": true - }, - { - "id": 142, - "name": "Multi Channel Association", - "version": 3, - "isSecure": true - }, - { - "id": 106, - "name": "Window Covering", - "version": 1, - "isSecure": true - }, - { - "id": 152, - "name": "Security", - "version": 1, - "isSecure": true - } - ] + "isFrequentListening": "1000ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0287:0x0004:0x0072:3.12.1", + "statistics": { + "commandsTX": 95, + "commandsRX": 110, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 1295.6, + "lastSeen": "2023-11-02T18:41:40.552Z", + "rssi": -69, + "lwr": { + "protocolDataRate": 2, + "repeaters": [], + "rssi": -71, + "repeaterRSSI": [] } - ], + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-11-02T18:41:40.552Z", "values": [ - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 2, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 2, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "duration", - "propertyName": "duration", - "ccVersion": 2, - "metadata": { - "type": "duration", - "readable": true, - "writeable": false, - "label": "Remaining duration", - "stateful": true, - "secret": false - }, - "value": { - "value": 0, - "unit": "seconds" - } - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "min": 0, - "max": 99, - "stateful": true, - "secret": false - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "duration", - "propertyName": "duration", - "ccVersion": 4, - "metadata": { - "type": "duration", - "readable": true, - "writeable": false, - "label": "Remaining duration", - "stateful": true, - "secret": false - }, - "value": { - "value": 0, - "unit": "seconds" - } - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Current value", - "min": 0, - "max": 99, - "stateful": true, - "secret": false - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "Up", - "propertyName": "Up", - "ccVersion": 4, - "metadata": { - "type": "boolean", - "readable": false, - "writeable": true, - "label": "Perform a level change (Up)", - "ccSpecific": { - "switchType": 2 - }, - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "Down", - "propertyName": "Down", - "ccVersion": 4, - "metadata": { - "type": "boolean", - "readable": false, - "writeable": true, - "label": "Perform a level change (Down)", - "ccSpecific": { - "switchType": 2 - }, - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "restorePrevious", - "propertyName": "restorePrevious", - "ccVersion": 4, - "metadata": { - "type": "boolean", - "readable": false, - "writeable": true, - "label": "Restore previous value", - "stateful": true, - "secret": false - } - }, { "endpoint": 0, "commandClass": 106, @@ -361,7 +102,7 @@ "propertyKey": 23, "propertyName": "currentValue", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "number", "readable": true, @@ -373,9 +114,9 @@ "min": 0, "max": 99, "states": { - "0": "Closed (up)", + "0": "Closed (up inside)", "50": "Open", - "99": "Closed (down)" + "99": "Closed (down inside)" }, "stateful": true, "secret": false @@ -390,7 +131,7 @@ "propertyKey": 23, "propertyName": "targetValue", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "number", "readable": true, @@ -403,14 +144,14 @@ "min": 0, "max": 99, "states": { - "0": "Closed (up)", + "0": "Closed (up inside)", "50": "Open", - "99": "Closed (down)" + "99": "Closed (down inside)" }, "stateful": true, "secret": false }, - "value": 99 + "value": 0 }, { "endpoint": 0, @@ -420,7 +161,7 @@ "propertyKey": 23, "propertyName": "duration", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "duration", "readable": true, @@ -441,44 +182,24 @@ "endpoint": 0, "commandClass": 106, "commandClassName": "Window Covering", - "property": "open", + "property": "levelChangeUp", "propertyKey": 23, - "propertyName": "open", + "propertyName": "levelChangeUp", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "boolean", "readable": false, "writeable": true, - "label": "Open - Horizontal Slats Angle", + "label": "Change tilt (down inside) - Horizontal Slats Angle", "ccSpecific": { "parameter": 23 }, "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "nodeId": 12, - "value": true - }, - { - "endpoint": 0, - "commandClass": 106, - "commandClassName": "Window Covering", - "property": "close0", - "propertyKey": 23, - "propertyName": "close0", - "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, - "metadata": { - "type": "boolean", - "readable": false, - "writeable": true, - "label": "Close Up - Horizontal Slats Angle", - "ccSpecific": { - "parameter": 23 + "states": { + "true": "Start", + "false": "Stop" }, - "valueChangeOptions": ["transitionDuration"], "stateful": true, "secret": false } @@ -487,25 +208,27 @@ "endpoint": 0, "commandClass": 106, "commandClassName": "Window Covering", - "property": "close99", + "property": "levelChangeDown", "propertyKey": 23, - "propertyName": "close99", + "propertyName": "levelChangeDown", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "boolean", "readable": false, "writeable": true, - "label": "Close Down - Horizontal Slats Angle", + "label": "Change tilt (up inside) - Horizontal Slats Angle", "ccSpecific": { "parameter": 23 }, "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, "stateful": true, "secret": false - }, - "nodeId": 12, - "value": true + } }, { "endpoint": 0, @@ -604,7 +327,7 @@ "allowManualEntry": true, "isFromConfig": true }, - "value": 50 + "value": 45 }, { "endpoint": 0, @@ -656,6 +379,32 @@ }, "value": 0 }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "MC", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "MC", + "label": "MC", + "default": 1, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "noBulkSupport": true, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, { "endpoint": 0, "commandClass": 112, @@ -721,7 +470,9 @@ "format": 0, "allowManualEntry": true, "isFromConfig": true - } + }, + "nodeId": 131, + "value": 99 }, { "endpoint": 0, @@ -1169,7 +920,9 @@ "max": 255, "stateful": true, "secret": false - } + }, + "nodeId": 131, + "value": 47 }, { "endpoint": 0, @@ -1183,54 +936,209 @@ "readable": false, "writeable": true, "label": "Identify", + "states": { + "true": "Identify" + }, "stateful": true, "secret": false } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "property": "targetValue", + "endpoint": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "propertyName": "targetValue", + "nodeId": 131, + "value": 45 + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "property": "duration", + "endpoint": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "propertyName": "duration", + "nodeId": 131, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "property": "currentValue", + "endpoint": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "propertyName": "currentValue", + "nodeId": 131, + "value": 45 } ], - "isFrequentListening": "1000ms", - "maxDataRate": 100000, - "supportedDataRates": [40000, 100000], - "protocolVersion": 3, - "supportsBeaming": true, - "supportsSecurity": false, - "nodeType": 1, - "zwavePlusNodeType": 0, - "zwavePlusRoleType": 7, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 17, - "label": "Multilevel Switch" - }, - "specific": { - "key": 7, - "label": "Motor Control Class C" - }, - "mandatorySupportedCCs": [32, 38, 37, 114, 134], - "mandatoryControlledCCs": [] - }, - "interviewStage": "Complete", - "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0287:0x0004:0x0072:3.12.1", - "statistics": { - "commandsTX": 109, - "commandsRX": 101, - "commandsDroppedRX": 2, - "commandsDroppedTX": 0, - "timeoutResponse": 8, - "rtt": 1217.2, - "rssi": -43, - "lwr": { - "protocolDataRate": 2, - "repeaters": [], - "rssi": -45, - "repeaterRSSI": [] + "endpoints": [ + { + "nodeId": 131, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": false + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 106, + "name": "Window Covering", + "version": 1, + "isSecure": false + } + ] } - }, - "highestSecurityClass": 1, - "isControllerNode": false, - "keepAwake": false + ] } diff --git a/tests/components/zwave_js/fixtures/zooz_zse44_state.json b/tests/components/zwave_js/fixtures/zooz_zse44_state.json index a2fb5421fb7..982708aaa11 100644 --- a/tests/components/zwave_js/fixtures/zooz_zse44_state.json +++ b/tests/components/zwave_js/fixtures/zooz_zse44_state.json @@ -88,140 +88,6 @@ "isControllerNode": false, "keepAwake": false, "lastSeen": "2023-08-09T13:26:05.031Z", - "endpoints": { - "0": { - "nodeId": 23, - "index": 0, - "installerIcon": 3327, - "userIcon": 3327, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 7, - "label": "Notification Sensor" - }, - "specific": { - "key": 1, - "label": "Notification Sensor" - }, - "mandatorySupportedCCs": [], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 94, - "name": "Z-Wave Plus Info", - "version": 2, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 3, - "isSecure": true - }, - { - "id": 142, - "name": "Multi Channel Association", - "version": 4, - "isSecure": true - }, - { - "id": 89, - "name": "Association Group Information", - "version": 3, - "isSecure": true - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 11, - "isSecure": true - }, - { - "id": 85, - "name": "Transport Service", - "version": 2, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 3, - "isSecure": true - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 2, - "isSecure": true - }, - { - "id": 90, - "name": "Device Reset Locally", - "version": 1, - "isSecure": true - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": true - }, - { - "id": 128, - "name": "Battery", - "version": 3, - "isSecure": true - }, - { - "id": 159, - "name": "Security 2", - "version": 1, - "isSecure": true - }, - { - "id": 113, - "name": "Notification", - "version": 8, - "isSecure": true - }, - { - "id": 135, - "name": "Indicator", - "version": 4, - "isSecure": true - }, - { - "id": 112, - "name": "Configuration", - "version": 4, - "isSecure": true - }, - { - "id": 132, - "name": "Wake Up", - "version": 1, - "isSecure": true - }, - { - "id": 108, - "name": "Supervision", - "version": 2, - "isSecure": false - }, - { - "id": 122, - "name": "Firmware Update Meta Data", - "version": 7, - "isSecure": true - } - ] - } - }, "values": [ { "endpoint": 0, @@ -1326,5 +1192,139 @@ }, "value": 0 } + ], + "endpoints": [ + { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 3, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 7, + "isSecure": true + } + ] + } ] } diff --git a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py index d1e12e7abb4..ee03d57f4c7 100644 --- a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py +++ b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py @@ -36,6 +36,10 @@ def test_fixture_functions() -> None: old_diagnostics_format_data["data"]["state"]["values"] = list( old_diagnostics_format_data["data"]["state"]["values"].values() ) + old_diagnostics_format_data["data"]["state"]["endpoints"] = list( + old_diagnostics_format_data["data"]["state"]["endpoints"].values() + ) + assert ( extract_fixture_data(old_diagnostics_format_data) == old_diagnostics_format_data["data"]["state"] @@ -54,7 +58,6 @@ def test_load_file() -> None: def test_main(capfd: pytest.CaptureFixture[str]) -> None: """Test main function.""" - Path(__file__).parents[1] / "fixtures" / "zooz_zse44_state.json" fixture_str = load_fixture("zwave_js/zooz_zse44_state.json") fixture_dict = json.loads(fixture_str) diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index fc593de883b..54be2b43765 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -829,7 +829,7 @@ async def test_iblinds_v3_cover( hass: HomeAssistant, client, iblinds_v3, integration ) -> None: """Test iBlinds v3 cover which uses Window Covering CC.""" - entity_id = "cover.window_blind_controller_horizontal_slats_angle" + entity_id = "cover.blind_west_bed_1_horizontal_slats_angle" state = hass.states.get(entity_id) assert state # This device has no state because there is no position value @@ -854,7 +854,7 @@ async def test_iblinds_v3_cover( assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" - assert args["nodeId"] == 12 + assert args["nodeId"] == 131 assert args["valueId"] == { "endpoint": 0, "commandClass": 106, @@ -875,7 +875,7 @@ async def test_iblinds_v3_cover( assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" - assert args["nodeId"] == 12 + assert args["nodeId"] == 131 assert args["valueId"] == { "endpoint": 0, "commandClass": 106, @@ -896,7 +896,7 @@ async def test_iblinds_v3_cover( assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" - assert args["nodeId"] == 12 + assert args["nodeId"] == 131 assert args["valueId"] == { "endpoint": 0, "commandClass": 106, @@ -917,11 +917,11 @@ async def test_iblinds_v3_cover( assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" - assert args["nodeId"] == 12 + assert args["nodeId"] == 131 assert args["valueId"] == { "endpoint": 0, "commandClass": 106, - "property": "open", + "property": "levelChangeUp", "propertyKey": 23, } assert args["value"] is False From 5bcff8214823a76d222b0f0673177bfe0411a97a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Nov 2023 09:41:47 -0500 Subject: [PATCH 183/982] Remove useless inner function in the base Bluetooth coordinator (#103305) Remove unless inner function in the base Bluetooth coordinator --- homeassistant/components/bluetooth/update_coordinator.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 12bff3be645..295e84d4481 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -47,12 +47,7 @@ class BasePassiveBluetoothCoordinator(ABC): def async_start(self) -> CALLBACK_TYPE: """Start the data updater.""" self._async_start() - - @callback - def _async_cancel() -> None: - self._async_stop() - - return _async_cancel + return self._async_stop @callback @abstractmethod From c1d979dc0779c079f747e7a97c96ec3e0216c0b8 Mon Sep 17 00:00:00 2001 From: Ian Date: Fri, 3 Nov 2023 09:03:02 -0700 Subject: [PATCH 184/982] Bump py_nextbusnext to v1.0.2 to fix TypeError (#103214) * Bump py_nextbusnext to v1.0.1 to fix TypeError Currently throwing an error as a set is passed into the method that is currently expecting a Sequence. That method is technically compatible with Iterable, so the latest patch relaxes that restriction. * Bump version to v1.0.2 to fix error message --- homeassistant/components/nextbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 9d1490a4ae6..d8f4018ada2 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==1.0.0"] + "requirements": ["py-nextbusnext==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7eb491b98df..668157c9f9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1524,7 +1524,7 @@ py-improv-ble-client==1.0.3 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==1.0.0 +py-nextbusnext==1.0.2 # homeassistant.components.nightscout py-nightscout==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1fdd20ad9e..7763382824e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1169,7 +1169,7 @@ py-improv-ble-client==1.0.3 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==1.0.0 +py-nextbusnext==1.0.2 # homeassistant.components.nightscout py-nightscout==1.2.2 From 1df69f52e5cb602ed65f16de447490063a731050 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 3 Nov 2023 17:05:27 +0100 Subject: [PATCH 185/982] Bump reolink-aio to 0.7.14 and improve typing of Reolink (#103129) * Improve typing * fix mypy * Further improve typing * Restore Literal typing * Bump reolink_aio to 0.7.13 * Bump reolink-aio to 0.7.14 --- homeassistant/components/reolink/__init__.py | 11 ++++++++--- .../components/reolink/binary_sensor.py | 6 +++--- homeassistant/components/reolink/host.py | 18 ++++++++++-------- homeassistant/components/reolink/light.py | 16 ++++++++-------- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/number.py | 4 ++-- homeassistant/components/reolink/sensor.py | 2 +- homeassistant/components/reolink/update.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 10 files changed, 37 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index fd62f8451fb..8425f29fbe8 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -10,6 +10,7 @@ from typing import Literal from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError +from reolink_aio.software_version import NewSoftwareVersion from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform @@ -45,7 +46,9 @@ class ReolinkData: host: ReolinkHost device_coordinator: DataUpdateCoordinator[None] - firmware_coordinator: DataUpdateCoordinator[str | Literal[False]] + firmware_coordinator: DataUpdateCoordinator[ + str | Literal[False] | NewSoftwareVersion + ] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -86,7 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): await host.renew() - async def async_check_firmware_update() -> str | Literal[False]: + async def async_check_firmware_update() -> str | Literal[ + False + ] | NewSoftwareVersion: """Check for firmware updates.""" if not host.api.supported(None, "update"): return False @@ -153,7 +158,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): +async def entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update the configuration of the host entity.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 49e964e2b3f..7f2ff3e0053 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -32,7 +32,7 @@ from .entity import ReolinkChannelCoordinatorEntity class ReolinkBinarySensorEntityDescriptionMixin: """Mixin values for Reolink binary sensor entities.""" - value: Callable[[Host, int | None], bool] + value: Callable[[Host, int], bool] @dataclass @@ -43,7 +43,7 @@ class ReolinkBinarySensorEntityDescription( icon: str = "mdi:motion-sensor" icon_off: str = "mdi:motion-sensor-off" - supported: Callable[[Host, int | None], bool] = lambda host, ch: True + supported: Callable[[Host, int], bool] = lambda host, ch: True BINARY_SENSORS = ( @@ -169,6 +169,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt ) ) - async def _async_handle_event(self, event): + async def _async_handle_event(self, event: str) -> None: """Handle incoming event for motion detection.""" self.async_write_ha_state() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index d470711267d..0075bbac4e6 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping import logging -from typing import Any +from typing import Any, Literal import aiohttp from aiohttp.web import Request @@ -81,7 +81,7 @@ class ReolinkHost: return self._unique_id @property - def api(self): + def api(self) -> Host: """Return the API object.""" return self._api @@ -313,7 +313,7 @@ class ReolinkHost: """Call the API of the camera device to update the internal states.""" await self._api.get_states() - async def disconnect(self): + async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" try: await self._api.unsubscribe() @@ -335,7 +335,7 @@ class ReolinkHost: err, ) - async def _async_start_long_polling(self, initial=False): + async def _async_start_long_polling(self, initial=False) -> None: """Start ONVIF long polling task.""" if self._long_poll_task is None: try: @@ -364,7 +364,7 @@ class ReolinkHost: self._lost_subscription = False self._long_poll_task = asyncio.create_task(self._async_long_polling()) - async def _async_stop_long_polling(self): + async def _async_stop_long_polling(self) -> None: """Stop ONVIF long polling task.""" if self._long_poll_task is not None: self._long_poll_task.cancel() @@ -372,7 +372,7 @@ class ReolinkHost: await self._api.unsubscribe(sub_type=SubType.long_poll) - async def stop(self, event=None): + async def stop(self, event=None) -> None: """Disconnect the API.""" if self._cancel_poll is not None: self._cancel_poll() @@ -433,7 +433,7 @@ class ReolinkHost: else: self._lost_subscription = False - async def _renew(self, sub_type: SubType) -> None: + async def _renew(self, sub_type: Literal[SubType.push, SubType.long_poll]) -> None: """Execute the renew of the subscription.""" if not self._api.subscribed(sub_type): _LOGGER.debug( @@ -512,8 +512,10 @@ class ReolinkHost: _LOGGER.debug("Registered webhook: %s", event_id) - def unregister_webhook(self): + def unregister_webhook(self) -> None: """Unregister the webhook for motion events.""" + if self.webhook_id is None: + return _LOGGER.debug("Unregistering webhook %s", self.webhook_id) webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 4ac8166410f..938093df4a3 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -38,8 +38,8 @@ class ReolinkLightEntityDescription( """A class that describes light entities.""" supported_fn: Callable[[Host, int], bool] = lambda api, ch: True - get_brightness_fn: Callable[[Host, int], int] | None = None - set_brightness_fn: Callable[[Host, int, float], Any] | None = None + get_brightness_fn: Callable[[Host, int], int | None] | None = None + set_brightness_fn: Callable[[Host, int, int], Any] | None = None LIGHT_ENTITIES = ( @@ -127,13 +127,13 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): if self.entity_description.get_brightness_fn is None: return None - return round( - 255 - * ( - self.entity_description.get_brightness_fn(self._host.api, self._channel) - / 100.0 - ) + bright_pct = self.entity_description.get_brightness_fn( + self._host.api, self._channel ) + if bright_pct is None: + return None + + return round(255 * bright_pct / 100.0) async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 1c1d8dd96b1..9189de89efa 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.7.12"] + "requirements": ["reolink-aio==0.7.14"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 24e5d1bd72b..6be0cef1670 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -26,7 +26,7 @@ from .entity import ReolinkChannelCoordinatorEntity class ReolinkNumberEntityDescriptionMixin: """Mixin values for Reolink number entities.""" - value: Callable[[Host, int], float] + value: Callable[[Host, int], float | None] method: Callable[[Host, int, float], Any] @@ -354,7 +354,7 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): ) @property - def native_value(self) -> float: + def native_value(self) -> float | None: """State of the number entity.""" return self.entity_description.value(self._host.api, self._channel) diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 6282f29e442..b9e8ddb8e73 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -44,7 +44,7 @@ class ReolinkSensorEntityDescription( class ReolinkHostSensorEntityDescriptionMixin: """Mixin values for Reolink host sensor entities.""" - value: Callable[[Host], int] + value: Callable[[Host], int | None] @dataclass diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 57efe1d9e92..1c10671550d 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -35,7 +35,8 @@ async def async_setup_entry( class ReolinkUpdateEntity( - ReolinkBaseCoordinatorEntity[str | Literal[False]], UpdateEntity + ReolinkBaseCoordinatorEntity[str | Literal[False] | NewSoftwareVersion], + UpdateEntity, ): """Update entity for a Netgear device.""" diff --git a/requirements_all.txt b/requirements_all.txt index 668157c9f9b..b44bde07de5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2322,7 +2322,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.12 +reolink-aio==0.7.14 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7763382824e..fddf181b168 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1733,7 +1733,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.12 +reolink-aio==0.7.14 # homeassistant.components.rflink rflink==0.0.65 From fd8caaf84694534ccbec0caabb55cd160a224063 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Nov 2023 11:10:08 -0500 Subject: [PATCH 186/982] Bump SQLAlchemy to 2.0.23 (#103313) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f0e91071ea0..b630a71daff 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.22", + "SQLAlchemy==2.0.23", "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index e570f6bac0b..c63ba19e0ad 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.22"] + "requirements": ["SQLAlchemy==2.0.23"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2843a1b418d..62a8b722dc9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.22 +SQLAlchemy==2.0.23 typing-extensions>=4.8.0,<5.0 ulid-transform==0.9.0 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index b44bde07de5..22abb8ab832 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -132,7 +132,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.22 +SQLAlchemy==2.0.23 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fddf181b168..c6da8673336 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.22 +SQLAlchemy==2.0.23 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 From 88850334f1543608648dd74badf39f13c1f92918 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 3 Nov 2023 17:16:20 +0100 Subject: [PATCH 187/982] Fix typo in Todoist config flow (#103317) --- homeassistant/components/todoist/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 442114eb118..0f81702a4d0 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -5,7 +5,7 @@ "data": { "token": "[%key:common::config_flow::data::api_token%]" }, - "description": "Please entry your API token from your [Todoist Settings page]({settings_url})" + "description": "Please enter your API token from your [Todoist Settings page]({settings_url})" } }, "error": { From 921d6feae7438e62e9a38a80580065682d04e3c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 3 Nov 2023 17:44:48 +0100 Subject: [PATCH 188/982] Remove extra from traccar webhook (#103319) --- homeassistant/components/traccar/__init__.py | 3 ++- tests/components/traccar/test_init.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index c428ce7a5b1..5dffd629e80 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -50,7 +50,8 @@ WEBHOOK_SCHEMA = vol.Schema( vol.Optional(ATTR_BEARING): vol.Coerce(float), vol.Optional(ATTR_SPEED): vol.Coerce(float), vol.Optional(ATTR_TIMESTAMP): vol.Coerce(int), - } + }, + extra=vol.REMOVE_EXTRA, ) diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index ccae59932de..1ac7adfb549 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -153,6 +153,7 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None "speed": 100, "bearing": "105.32", "altitude": 102, + "charge": "true", } req = await client.post(url, params=data) @@ -165,6 +166,7 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None assert state.attributes["speed"] == 100.0 assert state.attributes["bearing"] == 105.32 assert state.attributes["altitude"] == 102.0 + assert "charge" not in state.attributes data = { "lat": str(HOME_LATITUDE), From 0f1c96ba97e184fd635d93cb7a1924c1d9517d03 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Fri, 3 Nov 2023 17:49:48 +0100 Subject: [PATCH 189/982] Add translations to Workday state attributes (#103320) --- .../components/workday/binary_sensor.py | 1 + homeassistant/components/workday/strings.json | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 6a541cc84e1..ebd665f38e7 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -129,6 +129,7 @@ class IsWorkdaySensor(BinarySensorEntity): _attr_has_entity_name = True _attr_name = None + _attr_translation_key = DOMAIN def __init__( self, diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index d0ffecd0f7e..733ea595ec7 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -129,5 +129,22 @@ } } } + }, + "entity": { + "binary_sensor": { + "workday": { + "state_attributes": { + "workdays": { + "name": "[%key:component::workday::config::step::options::data::workdays%]" + }, + "excludes": { + "name": "[%key:component::workday::config::step::options::data::excludes%]" + }, + "days_offset": { + "name": "[%key:component::workday::config::step::options::data::days_offset%]" + } + } + } + } } } From 4778c55d2bb9a256054de2ddfbcab6a2da1a1db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 3 Nov 2023 18:09:56 +0100 Subject: [PATCH 190/982] Bump pytraccar from 1.0.0 to 2.0.0 (#103318) --- .../components/traccar/device_tracker.py | 53 ++++++++++--------- .../components/traccar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index f1236a66700..3406997fd98 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -274,7 +274,8 @@ class TraccarScanner: """Import device data from Traccar.""" for position in self._positions: device = next( - (dev for dev in self._devices if dev.id == position.device_id), None + (dev for dev in self._devices if dev["id"] == position["deviceId"]), + None, ) if not device: @@ -282,36 +283,36 @@ class TraccarScanner: attr = { ATTR_TRACKER: "traccar", - ATTR_ADDRESS: position.address, - ATTR_SPEED: position.speed, - ATTR_ALTITUDE: position.altitude, - ATTR_MOTION: position.attributes.get("motion", False), - ATTR_TRACCAR_ID: device.id, + ATTR_ADDRESS: position["address"], + ATTR_SPEED: position["speed"], + ATTR_ALTITUDE: position["altitude"], + ATTR_MOTION: position["attributes"].get("motion", False), + ATTR_TRACCAR_ID: device["id"], ATTR_GEOFENCE: next( ( - geofence.name + geofence["name"] for geofence in self._geofences - if geofence.id in (device.geofence_ids or []) + if geofence["id"] in (position["geofenceIds"] or []) ), None, ), - ATTR_CATEGORY: device.category, - ATTR_STATUS: device.status, + ATTR_CATEGORY: device["category"], + ATTR_STATUS: device["status"], } skip_accuracy_filter = False for custom_attr in self._custom_attributes: - if device.attributes.get(custom_attr) is not None: - attr[custom_attr] = position.attributes[custom_attr] + if device["attributes"].get(custom_attr) is not None: + attr[custom_attr] = position["attributes"][custom_attr] if custom_attr in self._skip_accuracy_on: skip_accuracy_filter = True - if position.attributes.get(custom_attr) is not None: - attr[custom_attr] = position.attributes[custom_attr] + if position["attributes"].get(custom_attr) is not None: + attr[custom_attr] = position["attributes"][custom_attr] if custom_attr in self._skip_accuracy_on: skip_accuracy_filter = True - accuracy = position.accuracy or 0.0 + accuracy = position["accuracy"] or 0.0 if ( not skip_accuracy_filter and self._max_accuracy > 0 @@ -325,10 +326,10 @@ class TraccarScanner: continue await self._async_see( - dev_id=slugify(device.name), - gps=(position.latitude, position.longitude), + dev_id=slugify(device["name"]), + gps=(position["latitude"], position["longitude"]), gps_accuracy=accuracy, - battery=position.attributes.get("batteryLevel", -1), + battery=position["attributes"].get("batteryLevel", -1), attributes=attr, ) @@ -337,7 +338,7 @@ class TraccarScanner: # get_reports_events requires naive UTC datetimes as of 1.0.0 start_intervel = dt_util.utcnow().replace(tzinfo=None) events = await self._api.get_reports_events( - devices=[device.id for device in self._devices], + devices=[device["id"] for device in self._devices], start_time=start_intervel, end_time=start_intervel - self._scan_interval, event_types=self._event_types.keys(), @@ -345,20 +346,20 @@ class TraccarScanner: if events is not None: for event in events: self._hass.bus.async_fire( - f"traccar_{self._event_types.get(event.type)}", + f"traccar_{self._event_types.get(event['type'])}", { - "device_traccar_id": event.device_id, + "device_traccar_id": event["deviceId"], "device_name": next( ( - dev.name + dev["name"] for dev in self._devices - if dev.id == event.device_id + if dev["id"] == event["deviceId"] ), None, ), - "type": event.type, - "serverTime": event.event_time, - "attributes": event.attributes, + "type": event["type"], + "serverTime": event["eventTime"], + "attributes": event["attributes"], }, ) diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index 1c2cda69ffe..403ba3987ab 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/traccar", "iot_class": "local_polling", "loggers": ["pytraccar"], - "requirements": ["pytraccar==1.0.0", "stringcase==1.2.0"] + "requirements": ["pytraccar==2.0.0", "stringcase==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22abb8ab832..4a90b20c0f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2214,7 +2214,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.traccar -pytraccar==1.0.0 +pytraccar==2.0.0 # homeassistant.components.tradfri pytradfri[async]==9.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6da8673336..ae05caf404c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1649,7 +1649,7 @@ pytile==2023.04.0 pytomorrowio==0.3.6 # homeassistant.components.traccar -pytraccar==1.0.0 +pytraccar==2.0.0 # homeassistant.components.tradfri pytradfri[async]==9.0.1 From 062b510ec0e41abbbcefb6eeefaf2cbf113292a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Nov 2023 12:37:29 -0500 Subject: [PATCH 191/982] Cache the mime type of static files (#103281) --- homeassistant/components/http/static.py | 31 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 5e2c4a7a7a9..1ab4ef5bd6f 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,7 +1,8 @@ """Static file handling for HTTP component.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping +import mimetypes from pathlib import Path from typing import Final @@ -16,10 +17,11 @@ from homeassistant.core import HomeAssistant from .const import KEY_HASS CACHE_TIME: Final = 31 * 86400 # = 1 month -CACHE_HEADERS: Final[Mapping[str, str]] = { - hdrs.CACHE_CONTROL: f"public, max-age={CACHE_TIME}" -} -PATH_CACHE = LRU(512) +CACHE_HEADER = f"public, max-age={CACHE_TIME}" +CACHE_HEADERS: Mapping[str, str] = {hdrs.CACHE_CONTROL: CACHE_HEADER} +PATH_CACHE: MutableMapping[ + tuple[str, Path, bool], tuple[Path | None, str | None] +] = LRU(512) def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path | None: @@ -48,7 +50,7 @@ class CachingStaticResource(StaticResource): """Return requested file from disk as a FileResponse.""" rel_url = request.match_info["filename"] key = (rel_url, self._directory, self._follow_symlinks) - if (filepath := PATH_CACHE.get(key)) is None: + if (filepath_content_type := PATH_CACHE.get(key)) is None: hass: HomeAssistant = request.app[KEY_HASS] try: filepath = await hass.async_add_executor_job(_get_file_path, *key) @@ -62,13 +64,24 @@ class CachingStaticResource(StaticResource): # perm error or other kind! request.app.logger.exception(error) raise HTTPNotFound() from error - PATH_CACHE[key] = filepath - if filepath: + content_type: str | None = None + if filepath is not None: + content_type = (mimetypes.guess_type(rel_url))[ + 0 + ] or "application/octet-stream" + PATH_CACHE[key] = (filepath, content_type) + else: + filepath, content_type = filepath_content_type + + if filepath and content_type: return FileResponse( filepath, chunk_size=self._chunk_size, - headers=CACHE_HEADERS, + headers={ + hdrs.CACHE_CONTROL: CACHE_HEADER, + hdrs.CONTENT_TYPE: content_type, + }, ) return await super()._handle(request) From dca72c598ed025736603985cd16ab966397a6ba8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Nov 2023 14:58:03 -0500 Subject: [PATCH 192/982] Small speed up to async_listen (#103307) Avoid constructing an inner function each time we call async_listen and use a partial which holds a reference to the function body with new args instead of making another full function --- homeassistant/core.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 40e9da376d5..ab0fa3b6892 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1176,12 +1176,9 @@ class EventBus: self, event_type: str, filterable_job: _FilterableJobType ) -> CALLBACK_TYPE: self._listeners.setdefault(event_type, []).append(filterable_job) - - def remove_listener() -> None: - """Remove the listener.""" - self._async_remove_listener(event_type, filterable_job) - - return remove_listener + return functools.partial( + self._async_remove_listener, event_type, filterable_job + ) def listen_once( self, From 0ea0a1ed06983eb451225396979e71f527e0f36a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Nov 2023 21:01:38 +0100 Subject: [PATCH 193/982] Prevent accidentally reusing an entity object (#102911) * Prevent accidentally reusing an entity object * Fix group reload service * Revert "Fix group reload service" * Improve test * Add tests aserting entity can't be reused --- homeassistant/helpers/entity.py | 5 ++- tests/helpers/test_entity.py | 62 +++++++++++++++++++++++++++ tests/helpers/test_entity_platform.py | 32 ++++++++------ 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 207016aee86..0948e1ef808 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -987,7 +987,7 @@ class Entity(ABC): parallel_updates: asyncio.Semaphore | None, ) -> None: """Start adding an entity to a platform.""" - if self._platform_state == EntityPlatformState.ADDED: + if self._platform_state != EntityPlatformState.NOT_ADDED: raise HomeAssistantError( f"Entity '{self.entity_id}' cannot be added a second time to an entity" " platform" @@ -1009,7 +1009,7 @@ class Entity(ABC): def add_to_platform_abort(self) -> None: """Abort adding an entity to a platform.""" - self._platform_state = EntityPlatformState.NOT_ADDED + self._platform_state = EntityPlatformState.REMOVED self._call_on_remove_callbacks() self.hass = None # type: ignore[assignment] @@ -1156,6 +1156,7 @@ class Entity(ABC): await self.async_remove(force_remove=True) self.entity_id = registry_entry.entity_id + self._platform_state = EntityPlatformState.NOT_ADDED await self.platform.async_add_entities([self]) @callback diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index cf76083fe7a..26a4e48eb55 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1528,3 +1528,65 @@ async def test_suggest_report_issue_custom_component( suggestion = mock_entity._suggest_report_issue() assert suggestion == "create a bug report at https://some_url" + + +async def test_reuse_entity_object_after_abort(hass: HomeAssistant) -> None: + """Test reuse entity object.""" + platform = MockEntityPlatform(hass, domain="test") + ent = entity.Entity() + ent.entity_id = "invalid" + with pytest.raises(HomeAssistantError, match="Invalid entity ID: invalid"): + await platform.async_add_entities([ent]) + with pytest.raises( + HomeAssistantError, + match="Entity invalid cannot be added a second time to an entity platform", + ): + await platform.async_add_entities([ent]) + + +async def test_reuse_entity_object_after_entity_registry_remove( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test reuse entity object.""" + entry = entity_registry.async_get_or_create("test", "test", "5678") + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + ent = entity.Entity() + ent._attr_unique_id = "5678" + await platform.async_add_entities([ent]) + assert ent.registry_entry is entry + assert len(hass.states.async_entity_ids()) == 1 + + entity_registry.async_remove(entry.entity_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == 0 + + with pytest.raises( + HomeAssistantError, + match="Entity test.test_5678 cannot be added a second time", + ): + await platform.async_add_entities([ent]) + + +async def test_reuse_entity_object_after_entity_registry_disabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test reuse entity object.""" + entry = entity_registry.async_get_or_create("test", "test", "5678") + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + ent = entity.Entity() + ent._attr_unique_id = "5678" + await platform.async_add_entities([ent]) + assert ent.registry_entry is entry + assert len(hass.states.async_entity_ids()) == 1 + + entity_registry.async_update_entity( + entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == 0 + + with pytest.raises( + HomeAssistantError, + match="Entity test.test_5678 cannot be added a second time", + ): + await platform.async_add_entities([ent]) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 7ccbd5e0f28..af8fbf59049 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -565,24 +565,32 @@ async def test_async_remove_with_platform_update_finishes(hass: HomeAssistant) - component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_setup({}) entity1 = MockEntity(name="test_1") + entity2 = MockEntity(name="test_1") async def _delayed_update(*args, **kwargs): - await asyncio.sleep(0.01) + update_called.set() + await update_done.wait() entity1.async_update = _delayed_update + entity2.async_update = _delayed_update - # Add, remove, add, remove and make sure no updates - # cause the entity to reappear after removal - for _ in range(2): - await component.async_add_entities([entity1]) - assert len(hass.states.async_entity_ids()) == 1 - entity1.async_write_ha_state() - assert hass.states.get(entity1.entity_id) is not None - task = asyncio.create_task(entity1.async_update_ha_state(True)) - await entity1.async_remove() - assert len(hass.states.async_entity_ids()) == 0 + # Add, remove, and make sure no updates + # cause the entity to reappear after removal and + # that we can add another entity with the same entity_id + for entity in [entity1, entity2]: + update_called = asyncio.Event() + update_done = asyncio.Event() + await component.async_add_entities([entity]) + assert hass.states.async_entity_ids() == ["test_domain.test_1"] + entity.async_write_ha_state() + assert hass.states.get(entity.entity_id) is not None + task = asyncio.create_task(entity.async_update_ha_state(True)) + await update_called.wait() + await entity.async_remove() + assert hass.states.async_entity_ids() == [] + update_done.set() await task - assert len(hass.states.async_entity_ids()) == 0 + assert hass.states.async_entity_ids() == [] async def test_not_adding_duplicate_entities_with_unique_id( From ae1117bc743a068d109e2ab2c4d3f21e0949daa0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 3 Nov 2023 23:19:37 +0100 Subject: [PATCH 194/982] Fix failing entity reuse test (#103342) * Fix failing entity reuse test * One more test --- tests/helpers/test_entity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 26a4e48eb55..a3ba5e48641 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1539,7 +1539,7 @@ async def test_reuse_entity_object_after_abort(hass: HomeAssistant) -> None: await platform.async_add_entities([ent]) with pytest.raises( HomeAssistantError, - match="Entity invalid cannot be added a second time to an entity platform", + match="Entity 'invalid' cannot be added a second time to an entity platform", ): await platform.async_add_entities([ent]) @@ -1562,7 +1562,7 @@ async def test_reuse_entity_object_after_entity_registry_remove( with pytest.raises( HomeAssistantError, - match="Entity test.test_5678 cannot be added a second time", + match="Entity 'test.test_5678' cannot be added a second time", ): await platform.async_add_entities([ent]) @@ -1587,6 +1587,6 @@ async def test_reuse_entity_object_after_entity_registry_disabled( with pytest.raises( HomeAssistantError, - match="Entity test.test_5678 cannot be added a second time", + match="Entity 'test.test_5678' cannot be added a second time", ): await platform.async_add_entities([ent]) From 51c3a5d11df7afe12d6c879b2b996ce123f78228 Mon Sep 17 00:00:00 2001 From: Ian Date: Sat, 4 Nov 2023 00:56:27 -0700 Subject: [PATCH 195/982] Nextbus: Listify directions (#103337) When a single value is returned, the list wrapper is not present in the json payload. This patch ensures that the result is always a list. --- .../components/nextbus/config_flow.py | 3 +- tests/components/nextbus/conftest.py | 39 +++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py index 000dd86eb52..84417a29c8d 100644 --- a/homeassistant/components/nextbus/config_flow.py +++ b/homeassistant/components/nextbus/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.helpers.selector import ( ) from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .util import listify _LOGGER = logging.getLogger(__name__) @@ -51,7 +52,7 @@ def _get_stop_tags( title_counts = Counter(tags.values()) stop_directions: dict[str, str] = {} - for direction in route_config["route"]["direction"]: + for direction in listify(route_config["route"]["direction"]): for stop in direction["stop"]: stop_directions[stop["tag"]] = direction["name"] diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py index 0940118c13a..4f6a6f22270 100644 --- a/tests/components/nextbus/conftest.py +++ b/tests/components/nextbus/conftest.py @@ -1,11 +1,37 @@ """Test helpers for NextBus tests.""" +from typing import Any from unittest.mock import MagicMock import pytest +@pytest.fixture( + params=[ + {"name": "Outbound", "stop": [{"tag": "5650"}]}, + [ + { + "name": "Outbound", + "stop": [{"tag": "5650"}], + }, + { + "name": "Inbound", + "stop": [{"tag": "5651"}], + }, + ], + ] +) +def route_config_direction(request: pytest.FixtureRequest) -> Any: + """Generate alternative directions values. + + When only on edirection is returned, it is not returned as a list, but instead an object. + """ + return request.param + + @pytest.fixture -def mock_nextbus_lists(mock_nextbus: MagicMock) -> MagicMock: +def mock_nextbus_lists( + mock_nextbus: MagicMock, route_config_direction: Any +) -> MagicMock: """Mock all list functions in nextbus to test validate logic.""" instance = mock_nextbus.return_value instance.get_agency_list.return_value = { @@ -22,16 +48,7 @@ def mock_nextbus_lists(mock_nextbus: MagicMock) -> MagicMock: # Error case test. Duplicate title with no unique direction {"tag": "5652", "title": "Market St & 7th St"}, ], - "direction": [ - { - "name": "Outbound", - "stop": [{"tag": "5650"}], - }, - { - "name": "Inbound", - "stop": [{"tag": "5651"}], - }, - ], + "direction": route_config_direction, } } From 62067fc64c0252bbf4cdabdf10f7c5a2c46b8d6a Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sat, 4 Nov 2023 12:43:20 +0400 Subject: [PATCH 196/982] Fix sensor unique id in Islamic prayer times (#103356) --- homeassistant/components/islamic_prayer_times/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index ee3c5d9071d..45270863f01 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -78,7 +78,7 @@ class IslamicPrayerTimeSensor( """Initialize the Islamic prayer time sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = description.key + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name=NAME, From 68471b6da5892c83335c42e5b52ff8372c462288 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 03:46:01 -0500 Subject: [PATCH 197/982] Reduce template render overhead (#103343) The contextmanager decorator creates a new context manager every time its run, but since we only have a single context var, we can use the same one every time. Creating the contextmanager was roughly 20% of the time time of the template render I was a bit suprised to find it creates a new context manager object every time https://stackoverflow.com/questions/34872535/why-contextmanager-is-slow --- homeassistant/helpers/template.py | 34 +++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 06280a26ccd..fa165da1772 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,7 +6,7 @@ import asyncio import base64 import collections.abc from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping -from contextlib import contextmanager, suppress +from contextlib import AbstractContextManager, suppress from contextvars import ContextVar from datetime import datetime, timedelta from functools import cache, lru_cache, partial, wraps @@ -20,7 +20,7 @@ import re import statistics from struct import error as StructError, pack, unpack_from import sys -from types import CodeType +from types import CodeType, TracebackType from typing import ( Any, Concatenate, @@ -504,7 +504,8 @@ class Template: def ensure_valid(self) -> None: """Return if template is valid.""" - with set_template(self.template, "compiling"): + with _template_context_manager as cm: + cm.set_template(self.template, "compiling") if self.is_static or self._compiled_code is not None: return @@ -2213,21 +2214,32 @@ def iif( return if_false -@contextmanager -def set_template(template_str: str, action: str) -> Generator: - """Store template being parsed or rendered in a Contextvar to aid error handling.""" - template_cv.set((template_str, action)) - try: - yield - finally: +class TemplateContextManager(AbstractContextManager): + """Context manager to store template being parsed or rendered in a ContextVar.""" + + def set_template(self, template_str: str, action: str) -> None: + """Store template being parsed or rendered in a Contextvar to aid error handling.""" + template_cv.set((template_str, action)) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Raise any exception triggered within the runtime context.""" template_cv.set(None) +_template_context_manager = TemplateContextManager() + + def _render_with_context( template_str: str, template: jinja2.Template, **kwargs: Any ) -> str: """Store template being rendered in a ContextVar to aid error handling.""" - with set_template(template_str, "rendering"): + with _template_context_manager as cm: + cm.set_template(template_str, "rendering") return template.render(**kwargs) From bb8375da72a78b3663a78392e25c9e2703e9b0d2 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 4 Nov 2023 09:48:02 +0100 Subject: [PATCH 198/982] Report correct weather condition at night for Met (#103334) * Report correct weather condition at night for Met, fixes #68369, fixes #89001 * Update homeassistant/components/met/weather.py Co-authored-by: Jan-Philipp Benecke --------- Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/met/weather.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 8a5c405c1c1..97b99e826cd 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -31,13 +31,21 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator -from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP +from .const import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_SUNNY, + ATTR_MAP, + CONDITIONS_MAP, + CONF_TRACK_HOME, + DOMAIN, + FORECAST_MAP, +) DEFAULT_NAME = "Met.no" @@ -141,6 +149,10 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): condition = self.coordinator.data.current_weather_data.get("condition") if condition is None: return None + + if condition == ATTR_CONDITION_SUNNY and not sun.is_up(self.hass): + condition = ATTR_CONDITION_CLEAR_NIGHT + return format_condition(condition) @property From 5cd27a877ea586c360599dab5160b1fa60762ddf Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 4 Nov 2023 10:51:34 +0100 Subject: [PATCH 199/982] Use `setdefault()` in scaffold script for setting `hass.data` (#103338) --- .../templates/config_flow_helper/integration/__init__.py | 2 +- .../templates/config_flow_oauth2/integration/__init__.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/script/scaffold/templates/config_flow_helper/integration/__init__.py b/script/scaffold/templates/config_flow_helper/integration/__init__.py index 9b4d4097036..2ad917394b9 100644 --- a/script/scaffold/templates/config_flow_helper/integration/__init__.py +++ b/script/scaffold/templates/config_flow_helper/integration/__init__.py @@ -11,7 +11,7 @@ from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NEW_NAME from a config entry.""" # TODO Optionally store an object for your platforms to access - # hass.data[DOMAIN][entry.entry_id] = ... + # hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ... # TODO Optionally validate config entry options before setting up platform diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index 8eb21d1cece..213740005e5 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -25,10 +25,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) # If using a requests-based API lib - hass.data[DOMAIN][entry.entry_id] = api.ConfigEntryAuth(hass, session) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api.ConfigEntryAuth( + hass, session + ) # If using an aiohttp-based API lib - hass.data[DOMAIN][entry.entry_id] = api.AsyncConfigEntryAuth( + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api.AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), session ) From f5fee73e01dceb3130d14337cb9b62ff364f934f Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 4 Nov 2023 11:15:54 +0100 Subject: [PATCH 200/982] Add translations to DWD state attributes (#103359) --- .../dwd_weather_warnings/strings.json | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index dc73055174b..aa460dcc6d5 100644 --- a/homeassistant/components/dwd_weather_warnings/strings.json +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -19,10 +19,38 @@ "entity": { "sensor": { "current_warning_level": { - "name": "Current warning level" + "name": "Current warning level", + "state_attributes": { + "region_name": { + "name": "Region name" + }, + "region_id": { + "name": "Region ID" + }, + "last_update": { + "name": "Last update" + }, + "warning_count": { + "name": "Warning count" + } + } }, "advance_warning_level": { - "name": "Advance warning level" + "name": "Advance warning level", + "state_attributes": { + "region_name": { + "name": "[%key:component::dwd_weather_warnings::entity::sensor::current_warning_level::state_attributes::region_name::name%]" + }, + "region_id": { + "name": "[%key:component::dwd_weather_warnings::entity::sensor::current_warning_level::state_attributes::region_id::name%]" + }, + "last_update": { + "name": "[%key:component::dwd_weather_warnings::entity::sensor::current_warning_level::state_attributes::last_update::name%]" + }, + "warning_count": { + "name": "[%key:component::dwd_weather_warnings::entity::sensor::current_warning_level::state_attributes::warning_count::name%]" + } + } } } } From 96409cf0e02c8c71ef66b860636025ea87fea5e9 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sat, 4 Nov 2023 11:28:26 +0100 Subject: [PATCH 201/982] Use pyatmo device type enum instead of string (#103030) --- homeassistant/components/netatmo/climate.py | 14 ++++++++++---- homeassistant/components/netatmo/data_handler.py | 7 +++++-- .../components/netatmo/netatmo_entity_base.py | 3 ++- homeassistant/components/netatmo/select.py | 4 +++- homeassistant/components/netatmo/sensor.py | 8 +++++++- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index f4715015844..b0d0439d72c 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -5,6 +5,7 @@ import logging from typing import Any, cast from pyatmo.modules import NATherm1 +from pyatmo.modules.device_types import DeviceType import voluptuous as vol from homeassistant.components.climate import ( @@ -106,8 +107,8 @@ CURRENT_HVAC_MAP_NETATMO = {True: HVACAction.HEATING, False: HVACAction.IDLE} DEFAULT_MAX_TEMP = 30 -NA_THERM = "NATherm1" -NA_VALVE = "NRV" +NA_THERM = DeviceType.NATherm1 +NA_VALVE = DeviceType.NRV async def async_setup_entry( @@ -117,6 +118,10 @@ async def async_setup_entry( @callback def _create_entity(netatmo_device: NetatmoRoom) -> None: + if not netatmo_device.room.climate_type: + msg = f"No climate type found for this room: {netatmo_device.room.name}" + _LOGGER.info(msg) + return entity = NetatmoThermostat(netatmo_device) async_add_entities([entity]) @@ -170,7 +175,8 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ] ) - self._model: str = f"{self._room.climate_type}" + assert self._room.climate_type + self._model: DeviceType = self._room.climate_type self._config_url = CONF_URL_ENERGY @@ -184,7 +190,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self._selected_schedule = None self._attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT] - if self._model == NA_THERM: + if self._model is NA_THERM: self._attr_hvac_modes.append(HVACMode.OFF) self._attr_unique_id = f"{self._room.entity_id}-{self._model}" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 8fa8ab2073d..e1d100f773e 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -12,7 +12,10 @@ from typing import Any import aiohttp import pyatmo -from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory +from pyatmo.modules.device_types import ( + DeviceCategory as NetatmoDeviceCategory, + DeviceType as NetatmoDeviceType, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -53,7 +56,7 @@ ACCOUNT = "account" HOME = "home" WEATHER = "weather" AIR_CARE = "air_care" -PUBLIC = "public" +PUBLIC = NetatmoDeviceType.public EVENT = "event" PUBLISHERS = { diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index 4cf5766b6b5..54915facb3a 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from pyatmo import DeviceType from pyatmo.modules.device_types import ( DEVICE_DESCRIPTION_MAP, DeviceType as NetatmoDeviceType, @@ -29,7 +30,7 @@ class NetatmoBase(Entity): self._device_name: str = "" self._id: str = "" - self._model: str = "" + self._model: DeviceType self._config_url: str | None = None self._attr_name = None self._attr_unique_id = None diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 3651ae05e88..b02c63698f3 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from pyatmo import DeviceType + from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -65,7 +67,7 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): self._device_name = self._home.name self._attr_name = f"{self._device_name}" - self._model: str = "NATherm1" + self._model = DeviceType.NATherm1 self._config_url = CONF_URL_ENERGY self._attr_unique_id = f"{self._home_id}-schedule-select" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index f286e53772c..fe6b44a0334 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -322,6 +322,10 @@ async def async_setup_entry( @callback def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: + if not netatmo_device.room.climate_type: + msg = f"No climate type found for this room: {netatmo_device.room.name}" + _LOGGER.info(msg) + return async_add_entities( NetatmoRoomSensor(netatmo_device, description) for description in SENSOR_TYPES @@ -633,9 +637,11 @@ class NetatmoRoomSensor(NetatmoBase, SensorEntity): self._attr_name = f"{self._room.name} {self.entity_description.name}" self._room_id = self._room.entity_id - self._model = f"{self._room.climate_type}" self._config_url = CONF_URL_ENERGY + assert self._room.climate_type + self._model = self._room.climate_type + self._attr_unique_id = ( f"{self._id}-{self._room.entity_id}-{self.entity_description.key}" ) From 22be56a05b4e7a05e50e841392023b64f7ab78be Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 4 Nov 2023 12:19:56 +0100 Subject: [PATCH 202/982] Handle UniFi traffic rules not supported on older versions (#103346) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f1fc4777467..ed8649896dd 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==64"], + "requirements": ["aiounifi==65"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 4a90b20c0f3..3cb3e06c7d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==64 +aiounifi==65 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae05caf404c..bf55bc4aeee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==64 +aiounifi==65 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From fa6d8d281d3770c99331eaabef45d2804cebf598 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sat, 4 Nov 2023 12:31:57 +0100 Subject: [PATCH 203/982] Change log level to debug in Netatmo (#103365) Change log level to debug --- homeassistant/components/netatmo/climate.py | 2 +- homeassistant/components/netatmo/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index b0d0439d72c..a14cadf45c4 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -120,7 +120,7 @@ async def async_setup_entry( def _create_entity(netatmo_device: NetatmoRoom) -> None: if not netatmo_device.room.climate_type: msg = f"No climate type found for this room: {netatmo_device.room.name}" - _LOGGER.info(msg) + _LOGGER.debug(msg) return entity = NetatmoThermostat(netatmo_device) async_add_entities([entity]) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index fe6b44a0334..10114a75f63 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -324,7 +324,7 @@ async def async_setup_entry( def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: if not netatmo_device.room.climate_type: msg = f"No climate type found for this room: {netatmo_device.room.name}" - _LOGGER.info(msg) + _LOGGER.debug(msg) return async_add_entities( NetatmoRoomSensor(netatmo_device, description) From 2b3d57859ee994e84f9b1474782662d78d457879 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 4 Nov 2023 08:12:06 -0400 Subject: [PATCH 204/982] Add test for firmware update scenario (#103314) --- homeassistant/components/zwave_js/update.py | 4 +++ tests/components/zwave_js/test_update.py | 36 +++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index e49eb8a2017..37cfdc68569 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -333,6 +333,10 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) ) + # Make sure these variables are set for the elif evaluation + state = None + latest_version = None + # If we have a complete previous state, use that to set the latest version if ( (state := await self.async_get_last_state()) diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 46dca7a35ec..9e17f25c708 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -694,6 +694,42 @@ async def test_update_entity_partial_restore_data( assert state.state == STATE_UNKNOWN +async def test_update_entity_partial_restore_data_2( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test second scenario where update entity has partial restore data.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_ON, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "10.8", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": None}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] is None + + async def test_update_entity_full_restore_data_skipped_version( hass: HomeAssistant, client, From 39c97f5b14ca5fefe541f660cd05bd8161fbe612 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 4 Nov 2023 13:14:24 +0100 Subject: [PATCH 205/982] Add translations to Tankerkoenig state attributes (#103363) --- .../components/tankerkoenig/strings.json | 103 +++++++++++++++++- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 43d444b2c46..7017c6e5fed 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -47,18 +47,113 @@ "entity": { "binary_sensor": { "status": { - "name": "Status" + "name": "Status", + "state_attributes": { + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + } + } } }, "sensor": { "e5": { - "name": "Super" + "name": "Super", + "state_attributes": { + "brand": { + "name": "Brand" + }, + "fuel_type": { + "name": "Fuel type" + }, + "station_name": { + "name": "Station name" + }, + "street": { + "name": "Street" + }, + "house_number": { + "name": "House number" + }, + "postcode": { + "name": "Postal code" + }, + "city": { + "name": "City" + }, + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + } + } }, "e10": { - "name": "Super E10" + "name": "Super E10", + "state_attributes": { + "brand": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::brand::name%]" + }, + "fuel_type": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::fuel_type::name%]" + }, + "station_name": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::station_name::name%]" + }, + "street": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::street::name%]" + }, + "house_number": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::house_number::name%]" + }, + "postcode": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::postcode::name%]" + }, + "city": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::city::name%]" + }, + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + } + } }, "diesel": { - "name": "Diesel" + "name": "Diesel", + "state_attributes": { + "brand": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::brand::name%]" + }, + "fuel_type": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::fuel_type::name%]" + }, + "station_name": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::station_name::name%]" + }, + "street": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::street::name%]" + }, + "house_number": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::house_number::name%]" + }, + "postcode": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::postcode::name%]" + }, + "city": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::city::name%]" + }, + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + } + } } } } From 37757f777f657c17c5749630924841394ca12672 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Sat, 4 Nov 2023 08:16:40 -0400 Subject: [PATCH 206/982] AirNow sensors should share device identifier (#103279) * Entities from the same config entry should have the same device identifier * Clean up unused device entries with no entities after sensor setup Co-authored-by: James Pan <32176676+jzpan1@users.noreply.github.com> Co-authored-by: Theo Ma <62950302+t1an-xyz@users.noreply.github.com> Co-authored-by: Jonathan McDevitt <69861492+Jonmcd1@users.noreply.github.com> Co-authored-by: Jadon Yack <86989502+jadonyack@users.noreply.github.com> Co-authored-by: Ashton Foley <121987068+foleyash@users.noreply.github.com> Co-authored-by: Leon Yan <138124222+leony7@users.noreply.github.com> --- homeassistant/components/airnow/__init__.py | 15 +++++++++++++++ homeassistant/components/airnow/sensor.py | 9 +++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 8fe2291d3b3..d7caaa120fc 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -50,6 +51,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Clean up unused device entries with no entities + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + for dev in device_entries: + dev_entities = er.async_entries_for_device( + entity_registry, dev.id, include_disabled_entities=True + ) + if not dev_entities: + device_registry.async_remove_device(dev.id) + return True diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index f9d35d50810..c6ab27a8497 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -148,13 +148,14 @@ class AirNowSensor(CoordinatorEntity[AirNowDataUpdateCoordinator], SensorEntity) ) -> None: """Initialize.""" super().__init__(coordinator) + + _device_id = f"{coordinator.latitude}-{coordinator.longitude}" + self.entity_description = description - self._attr_unique_id = ( - f"{coordinator.latitude}-{coordinator.longitude}-{description.key.lower()}" - ) + self._attr_unique_id = f"{_device_id}-{description.key.lower()}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._attr_unique_id)}, + identifiers={(DOMAIN, _device_id)}, manufacturer=DEFAULT_NAME, name=DEFAULT_NAME, ) From 6036fda2196efd89b3f47c9b4468987640b16e2b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 4 Nov 2023 13:19:23 +0100 Subject: [PATCH 207/982] Add DeviceInfo to NINA (#103361) --- homeassistant/components/nina/binary_sensor.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 568869ca402..6310f43ca54 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -46,7 +47,9 @@ async def async_setup_entry( for ent in coordinator.data: for i in range(0, message_slots): - entities.append(NINAMessage(coordinator, ent, regions[ent], i + 1)) + entities.append( + NINAMessage(coordinator, ent, regions[ent], i + 1, config_entry) + ) async_add_entities(entities) @@ -54,12 +57,15 @@ async def async_setup_entry( class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity): """Representation of an NINA warning.""" + _attr_device_class = BinarySensorDeviceClass.SAFETY + def __init__( self, coordinator: NINADataUpdateCoordinator, region: str, region_name: str, slot_id: int, + config_entry: ConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator) @@ -69,7 +75,10 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti self._attr_name = f"Warning: {region_name} {slot_id}" self._attr_unique_id = f"{region}-{slot_id}" - self._attr_device_class = BinarySensorDeviceClass.SAFETY + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="NINA", + ) @property def is_on(self) -> bool: From aa2a748235bdb96028472068104bbaa7652ea944 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 4 Nov 2023 13:20:16 +0100 Subject: [PATCH 208/982] Add translations to speedtest.net state attributes (#103362) --- .../components/speedtestdotnet/strings.json | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/strings.json b/homeassistant/components/speedtestdotnet/strings.json index 740716db78e..72ebec6c9e0 100644 --- a/homeassistant/components/speedtestdotnet/strings.json +++ b/homeassistant/components/speedtestdotnet/strings.json @@ -21,13 +21,52 @@ "entity": { "sensor": { "ping": { - "name": "Ping" + "name": "Ping", + "state_attributes": { + "server_name": { + "name": "Server name" + }, + "server_country": { + "name": "Server country" + }, + "server_id": { + "name": "Server ID" + } + } }, "download": { - "name": "Download" + "name": "Download", + "state_attributes": { + "server_name": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_name::name%]" + }, + "server_country": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_country::name%]" + }, + "server_id": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_id::name%]" + }, + "bytes_received": { + "name": "Bytes received" + } + } }, "upload": { - "name": "Upload" + "name": "Upload", + "state_attributes": { + "server_name": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_name::name%]" + }, + "server_country": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_country::name%]" + }, + "server_id": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_id::name%]" + }, + "bytes_sent": { + "name": "Bytes sent" + } + } } } } From 109944e4ffe7d47f13dac93500593d91d0a657c1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Nov 2023 13:37:08 +0100 Subject: [PATCH 209/982] Replace/restructure HomeWizard device fixtures to reflect reality (#103311) --- tests/components/homewizard/conftest.py | 54 +- .../data.json} | 0 .../device.json} | 0 .../{ => HWE-P1-unused-exports}/system.json | 0 .../{data-HWE-P1.json => HWE-P1/data.json} | 0 .../homewizard/fixtures/HWE-P1/device.json | 7 + .../homewizard/fixtures/HWE-P1/system.json | 3 + .../homewizard/fixtures/HWE-SKT/data.json | 46 + .../device.json} | 0 .../fixtures/{ => HWE-SKT}/state.json | 0 .../homewizard/fixtures/HWE-SKT/system.json | 3 + .../homewizard/fixtures/SDM230/data.json | 46 + .../device.json} | 0 .../homewizard/fixtures/SDM230/system.json | 3 + .../snapshots/test_diagnostics.ambr | 227 +- .../homewizard/snapshots/test_number.ambr | 6 +- .../homewizard/snapshots/test_sensor.ambr | 2358 +---------------- .../homewizard/snapshots/test_switch.ambr | 18 +- tests/components/homewizard/test_button.py | 2 +- .../components/homewizard/test_diagnostics.py | 4 +- tests/components/homewizard/test_number.py | 2 +- tests/components/homewizard/test_sensor.py | 7 +- tests/components/homewizard/test_switch.py | 4 +- 23 files changed, 386 insertions(+), 2404 deletions(-) rename tests/components/homewizard/fixtures/{data-HWE-P1-unused-exports.json => HWE-P1-unused-exports/data.json} (100%) rename tests/components/homewizard/fixtures/{device-HWE-P1.json => HWE-P1-unused-exports/device.json} (100%) rename tests/components/homewizard/fixtures/{ => HWE-P1-unused-exports}/system.json (100%) rename tests/components/homewizard/fixtures/{data-HWE-P1.json => HWE-P1/data.json} (100%) create mode 100644 tests/components/homewizard/fixtures/HWE-P1/device.json create mode 100644 tests/components/homewizard/fixtures/HWE-P1/system.json create mode 100644 tests/components/homewizard/fixtures/HWE-SKT/data.json rename tests/components/homewizard/fixtures/{device-HWE-SKT.json => HWE-SKT/device.json} (100%) rename tests/components/homewizard/fixtures/{ => HWE-SKT}/state.json (100%) create mode 100644 tests/components/homewizard/fixtures/HWE-SKT/system.json create mode 100644 tests/components/homewizard/fixtures/SDM230/data.json rename tests/components/homewizard/fixtures/{device-sdm230.json => SDM230/device.json} (100%) create mode 100644 tests/components/homewizard/fixtures/SDM230/system.json diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index 9124504b23e..e778c82928b 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch +from homewizard_energy.errors import NotFoundError from homewizard_energy.models import Data, Device, State, System import pytest @@ -10,39 +11,18 @@ from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, get_fixture_path, load_fixture @pytest.fixture def device_fixture() -> str: - """Return the device fixture for a specific device.""" - return "device-HWE-P1.json" - - -@pytest.fixture -def data_fixture() -> str: - """Return the data fixture for a specific device.""" - return "data-HWE-P1.json" - - -@pytest.fixture -def state_fixture() -> str: - """Return the state fixture for a specific device.""" - return "state.json" - - -@pytest.fixture -def system_fixture() -> str: - """Return the system fixture for a specific device.""" - return "system.json" + """Return the device fixtures for a specific device.""" + return "HWE-P1" @pytest.fixture def mock_homewizardenergy( device_fixture: str, - data_fixture: str, - state_fixture: str, - system_fixture: str, ) -> MagicMock: """Return a mock bridge.""" with patch( @@ -53,18 +33,28 @@ def mock_homewizardenergy( new=homewizard, ): client = homewizard.return_value + client.device.return_value = Device.from_dict( - json.loads(load_fixture(device_fixture, DOMAIN)) + json.loads(load_fixture(f"{device_fixture}/device.json", DOMAIN)) ) client.data.return_value = Data.from_dict( - json.loads(load_fixture(data_fixture, DOMAIN)) - ) - client.state.return_value = State.from_dict( - json.loads(load_fixture(state_fixture, DOMAIN)) - ) - client.system.return_value = System.from_dict( - json.loads(load_fixture(system_fixture, DOMAIN)) + json.loads(load_fixture(f"{device_fixture}/data.json", DOMAIN)) ) + + if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): + client.state.return_value = State.from_dict( + json.loads(load_fixture(f"{device_fixture}/state.json", DOMAIN)) + ) + else: + client.state.side_effect = NotFoundError + + if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): + client.system.return_value = System.from_dict( + json.loads(load_fixture(f"{device_fixture}/system.json", DOMAIN)) + ) + else: + client.system.side_effect = NotFoundError + yield client diff --git a/tests/components/homewizard/fixtures/data-HWE-P1-unused-exports.json b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/data.json similarity index 100% rename from tests/components/homewizard/fixtures/data-HWE-P1-unused-exports.json rename to tests/components/homewizard/fixtures/HWE-P1-unused-exports/data.json diff --git a/tests/components/homewizard/fixtures/device-HWE-P1.json b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json similarity index 100% rename from tests/components/homewizard/fixtures/device-HWE-P1.json rename to tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json diff --git a/tests/components/homewizard/fixtures/system.json b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/system.json similarity index 100% rename from tests/components/homewizard/fixtures/system.json rename to tests/components/homewizard/fixtures/HWE-P1-unused-exports/system.json diff --git a/tests/components/homewizard/fixtures/data-HWE-P1.json b/tests/components/homewizard/fixtures/HWE-P1/data.json similarity index 100% rename from tests/components/homewizard/fixtures/data-HWE-P1.json rename to tests/components/homewizard/fixtures/HWE-P1/data.json diff --git a/tests/components/homewizard/fixtures/HWE-P1/device.json b/tests/components/homewizard/fixtures/HWE-P1/device.json new file mode 100644 index 00000000000..4972c491859 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "3c39e7aabbcc", + "firmware_version": "4.19", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-P1/system.json b/tests/components/homewizard/fixtures/HWE-P1/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/HWE-SKT/data.json b/tests/components/homewizard/fixtures/HWE-SKT/data.json new file mode 100644 index 00000000000..7e647952982 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-SKT/data.json @@ -0,0 +1,46 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 94, + "smr_version": null, + "meter_model": null, + "unique_meter_id": null, + "active_tariff": null, + "total_power_import_kwh": null, + "total_power_import_t1_kwh": 63.651, + "total_power_import_t2_kwh": null, + "total_power_import_t3_kwh": null, + "total_power_import_t4_kwh": null, + "total_power_export_kwh": null, + "total_power_export_t1_kwh": 0, + "total_power_export_t2_kwh": null, + "total_power_export_t3_kwh": null, + "total_power_export_t4_kwh": null, + "active_power_w": 1457.277, + "active_power_l1_w": 1457.277, + "active_power_l2_w": null, + "active_power_l3_w": null, + "active_voltage_l1_v": null, + "active_voltage_l2_v": null, + "active_voltage_l3_v": null, + "active_current_l1_a": null, + "active_current_l2_a": null, + "active_current_l3_a": null, + "active_frequency_hz": null, + "voltage_sag_l1_count": null, + "voltage_sag_l2_count": null, + "voltage_sag_l3_count": null, + "voltage_swell_l1_count": null, + "voltage_swell_l2_count": null, + "voltage_swell_l3_count": null, + "any_power_fail_count": null, + "long_power_fail_count": null, + "active_power_average_w": null, + "monthly_power_peak_w": null, + "monthly_power_peak_timestamp": null, + "total_gas_m3": null, + "gas_timestamp": null, + "gas_unique_id": null, + "active_liter_lpm": null, + "total_liter_m3": null, + "external_devices": null +} diff --git a/tests/components/homewizard/fixtures/device-HWE-SKT.json b/tests/components/homewizard/fixtures/HWE-SKT/device.json similarity index 100% rename from tests/components/homewizard/fixtures/device-HWE-SKT.json rename to tests/components/homewizard/fixtures/HWE-SKT/device.json diff --git a/tests/components/homewizard/fixtures/state.json b/tests/components/homewizard/fixtures/HWE-SKT/state.json similarity index 100% rename from tests/components/homewizard/fixtures/state.json rename to tests/components/homewizard/fixtures/HWE-SKT/state.json diff --git a/tests/components/homewizard/fixtures/HWE-SKT/system.json b/tests/components/homewizard/fixtures/HWE-SKT/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-SKT/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/SDM230/data.json b/tests/components/homewizard/fixtures/SDM230/data.json new file mode 100644 index 00000000000..e4eb045dff2 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/data.json @@ -0,0 +1,46 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 92, + "smr_version": null, + "meter_model": null, + "unique_meter_id": null, + "active_tariff": null, + "total_power_import_kwh": 2.705, + "total_power_import_t1_kwh": 2.705, + "total_power_import_t2_kwh": null, + "total_power_import_t3_kwh": null, + "total_power_import_t4_kwh": null, + "total_power_export_kwh": 255.551, + "total_power_export_t1_kwh": 255.551, + "total_power_export_t2_kwh": null, + "total_power_export_t3_kwh": null, + "total_power_export_t4_kwh": null, + "active_power_w": -1058.296, + "active_power_l1_w": -1058.296, + "active_power_l2_w": null, + "active_power_l3_w": null, + "active_voltage_l1_v": null, + "active_voltage_l2_v": null, + "active_voltage_l3_v": null, + "active_current_l1_a": null, + "active_current_l2_a": null, + "active_current_l3_a": null, + "active_frequency_hz": null, + "voltage_sag_l1_count": null, + "voltage_sag_l2_count": null, + "voltage_sag_l3_count": null, + "voltage_swell_l1_count": null, + "voltage_swell_l2_count": null, + "voltage_swell_l3_count": null, + "any_power_fail_count": null, + "long_power_fail_count": null, + "active_power_average_w": null, + "monthly_power_peak_w": null, + "monthly_power_peak_timestamp": null, + "total_gas_m3": null, + "gas_timestamp": null, + "gas_unique_id": null, + "active_liter_lpm": null, + "total_liter_m3": null, + "external_devices": null +} diff --git a/tests/components/homewizard/fixtures/device-sdm230.json b/tests/components/homewizard/fixtures/SDM230/device.json similarity index 100% rename from tests/components/homewizard/fixtures/device-sdm230.json rename to tests/components/homewizard/fixtures/SDM230/device.json diff --git a/tests/components/homewizard/fixtures/SDM230/system.json b/tests/components/homewizard/fixtures/SDM230/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 2a7f61fcf82..50ace69963d 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,75 +1,5 @@ # serializer version: 1 -# name: test_diagnostics - dict({ - 'data': dict({ - 'data': dict({ - 'active_current_l1_a': -4, - 'active_current_l2_a': 2, - 'active_current_l3_a': 0, - 'active_frequency_hz': 50, - 'active_liter_lpm': 12.345, - 'active_power_average_w': 123.0, - 'active_power_l1_w': -123, - 'active_power_l2_w': 456, - 'active_power_l3_w': 123.456, - 'active_power_w': -123, - 'active_tariff': 2, - 'active_voltage_l1_v': 230.111, - 'active_voltage_l2_v': 230.222, - 'active_voltage_l3_v': 230.333, - 'any_power_fail_count': 4, - 'external_devices': None, - 'gas_timestamp': '2021-03-14T11:22:33', - 'gas_unique_id': '**REDACTED**', - 'long_power_fail_count': 5, - 'meter_model': 'ISKRA 2M550T-101', - 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', - 'monthly_power_peak_w': 1111.0, - 'smr_version': 50, - 'total_gas_m3': 1122.333, - 'total_liter_m3': 1234.567, - 'total_energy_export_kwh': 13086.777, - 'total_energy_export_t1_kwh': 4321.333, - 'total_energy_export_t2_kwh': 8765.444, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 13779.338, - 'total_energy_import_t1_kwh': 10830.511, - 'total_energy_import_t2_kwh': 2948.827, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'unique_meter_id': '**REDACTED**', - 'voltage_sag_l1_count': 1, - 'voltage_sag_l2_count': 2, - 'voltage_sag_l3_count': 3, - 'voltage_swell_l1_count': 4, - 'voltage_swell_l2_count': 5, - 'voltage_swell_l3_count': 6, - 'wifi_ssid': '**REDACTED**', - 'wifi_strength': 100, - }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '2.11', - 'product_name': 'P1 Meter', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), - 'state': dict({ - 'brightness': 255, - 'power_on': True, - 'switch_lock': False, - }), - 'system': dict({ - 'cloud_enabled': True, - }), - }), - 'entry': dict({ - 'ip_address': '**REDACTED**', - }), - }) -# --- -# name: test_diagnostics[device-HWE-P1.json] +# name: test_diagnostics[HWE-P1] dict({ 'data': dict({ 'data': dict({ @@ -138,54 +68,54 @@ }), }) # --- -# name: test_diagnostics[device-HWE-SKT.json] +# name: test_diagnostics[HWE-SKT] dict({ 'data': dict({ 'data': dict({ - 'active_current_l1_a': -4, - 'active_current_l2_a': 2, - 'active_current_l3_a': 0, - 'active_frequency_hz': 50, - 'active_liter_lpm': 12.345, - 'active_power_average_w': 123.0, - 'active_power_l1_w': -123, - 'active_power_l2_w': 456, - 'active_power_l3_w': 123.456, - 'active_power_w': -123, - 'active_tariff': 2, - 'active_voltage_l1_v': 230.111, - 'active_voltage_l2_v': 230.222, - 'active_voltage_l3_v': 230.333, - 'any_power_fail_count': 4, + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': None, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_l1_w': 1457.277, + 'active_power_l2_w': None, + 'active_power_l3_w': None, + 'active_power_w': 1457.277, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'any_power_fail_count': None, 'external_devices': None, - 'gas_timestamp': '2021-03-14T11:22:33', - 'gas_unique_id': '**REDACTED**', - 'long_power_fail_count': 5, - 'meter_model': 'ISKRA 2M550T-101', - 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', - 'monthly_power_peak_w': 1111.0, - 'smr_version': 50, - 'total_energy_export_kwh': 13086.777, - 'total_energy_export_t1_kwh': 4321.333, - 'total_energy_export_t2_kwh': 8765.444, - 'total_energy_export_t3_kwh': 8765.444, - 'total_energy_export_t4_kwh': 8765.444, - 'total_energy_import_kwh': 13779.338, - 'total_energy_import_t1_kwh': 10830.511, - 'total_energy_import_t2_kwh': 2948.827, - 'total_energy_import_t3_kwh': 2948.827, - 'total_energy_import_t4_kwh': 2948.827, - 'total_gas_m3': 1122.333, - 'total_liter_m3': 1234.567, - 'unique_meter_id': '**REDACTED**', - 'voltage_sag_l1_count': 1, - 'voltage_sag_l2_count': 2, - 'voltage_sag_l3_count': 3, - 'voltage_swell_l1_count': 4, - 'voltage_swell_l2_count': 5, - 'voltage_swell_l3_count': 6, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': None, + 'total_energy_export_t1_kwh': 0, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': None, + 'total_energy_import_t1_kwh': 63.651, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, 'wifi_ssid': '**REDACTED**', - 'wifi_strength': 100, + 'wifi_strength': 94, }), 'device': dict({ 'api_version': 'v1', @@ -211,3 +141,72 @@ }), }) # --- +# name: test_diagnostics[SDM230] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': None, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_l1_w': -1058.296, + 'active_power_l2_w': None, + 'active_power_l3_w': None, + 'active_power_w': -1058.296, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': 255.551, + 'total_energy_export_t1_kwh': 255.551, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 2.705, + 'total_energy_import_t1_kwh': 2.705, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 92, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'product_name': 'kWh meter', + 'product_type': 'SDM230-WIFI', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index ea12108e9de..436abc70ac1 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_number_entities[device-HWE-SKT.json] +# name: test_number_entities[HWE-SKT] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Status light brightness', @@ -17,7 +17,7 @@ 'state': '100', }) # --- -# name: test_number_entities[device-HWE-SKT.json].1 +# name: test_number_entities[HWE-SKT].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -53,7 +53,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_number_entities[device-HWE-SKT.json].2 +# name: test_number_entities[HWE-SKT].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 2890e81d603..930652ed513 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_average_demand:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -31,7 +31,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_average_demand:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_average_demand:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -62,7 +62,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_average_demand:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_average_demand:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -76,7 +76,7 @@ 'state': '123.0', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_average_demand] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -108,39 +108,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_1:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -173,7 +141,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_1:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -188,7 +156,7 @@ 'state': '-4', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_1] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -220,39 +188,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_2:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_2:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -285,7 +221,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_2:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -300,7 +236,7 @@ 'state': '2', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_2] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -332,39 +268,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_3:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_3:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -397,7 +301,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_3:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -412,7 +316,7 @@ 'state': '0', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_current_phase_3] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -444,39 +348,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_frequency:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_frequency:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_frequency:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -509,7 +381,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_frequency:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_frequency:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', @@ -524,7 +396,7 @@ 'state': '50', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_frequency] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -556,39 +428,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -621,7 +461,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -636,7 +476,7 @@ 'state': '-123', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -668,39 +508,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_1:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -733,7 +541,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_1:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -748,7 +556,7 @@ 'state': '-123', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_1] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -780,39 +588,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_2:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_2:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -845,7 +621,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_2:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -860,7 +636,7 @@ 'state': '456', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_2] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -892,39 +668,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_3:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_3:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -957,7 +701,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_3:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -972,7 +716,7 @@ 'state': '123.456', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_power_phase_3] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1004,39 +748,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_tariff:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_tariff:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_tariff:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1074,7 +786,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_tariff:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_tariff:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -1094,7 +806,7 @@ 'state': '2', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_tariff] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1126,39 +838,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_1:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1191,7 +871,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_1:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1206,7 +886,7 @@ 'state': '230.111', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_1] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1238,39 +918,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_2:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_2:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1303,7 +951,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_2:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1318,7 +966,7 @@ 'state': '230.222', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_2] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1350,39 +998,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_3:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_3:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1415,7 +1031,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_3:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1430,7 +1046,7 @@ 'state': '230.333', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_voltage_phase_3] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1462,39 +1078,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_water_usage:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_water_usage:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_water_usage:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1527,7 +1111,7 @@ 'unit_of_measurement': 'l/min', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_water_usage:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_water_usage:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Active water usage', @@ -1542,7 +1126,7 @@ 'state': '12.345', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_active_water_usage] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1574,39 +1158,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_dsmr_version:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_dsmr_version:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_dsmr_version:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1637,7 +1189,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_dsmr_version:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_dsmr_version:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device DSMR version', @@ -1650,7 +1202,7 @@ 'state': '50', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_dsmr_version] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1682,39 +1234,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_gas_meter_identifier:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_gas_meter_identifier:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1745,7 +1265,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_gas_meter_identifier:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Gas meter identifier', @@ -1758,7 +1278,7 @@ 'state': '01FFEEDDCCBBAA99887766554433221100', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_gas_meter_identifier] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1790,39 +1310,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_long_power_failures_detected:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_long_power_failures_detected:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1853,7 +1341,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_long_power_failures_detected:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Long power failures detected', @@ -1866,7 +1354,7 @@ 'state': '5', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_long_power_failures_detected] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1898,39 +1386,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_peak_demand_current_month:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_peak_demand_current_month:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1961,7 +1417,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_peak_demand_current_month:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -1975,7 +1431,7 @@ 'state': '1111.0', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_peak_demand_current_month] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2007,39 +1463,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_power_failures_detected:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_power_failures_detected:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_power_failures_detected:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2070,7 +1494,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_power_failures_detected:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_power_failures_detected:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Power failures detected', @@ -2083,7 +1507,7 @@ 'state': '4', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_power_failures_detected] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2115,39 +1539,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_identifier:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_identifier:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2178,7 +1570,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_identifier:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Smart meter identifier', @@ -2191,7 +1583,7 @@ 'state': '00112233445566778899AABBCCDDEEFF', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_identifier] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2223,39 +1615,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_model:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_model:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_model:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2286,7 +1646,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_model:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_model:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Smart meter model', @@ -2299,7 +1659,7 @@ 'state': 'ISKRA 2M550T-101', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_smart_meter_model] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2331,39 +1691,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2396,7 +1724,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2411,7 +1739,7 @@ 'state': '13086.777', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_1:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2443,7 +1771,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_1:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2476,7 +1804,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_1:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2491,7 +1819,7 @@ 'state': '4321.333', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_2:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2523,7 +1851,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_2:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2556,7 +1884,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_2:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2571,7 +1899,7 @@ 'state': '8765.444', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_3:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2603,7 +1931,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_3:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2636,7 +1964,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_3:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2651,7 +1979,7 @@ 'state': '8765.444', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_4:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2683,7 +2011,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_4:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2716,7 +2044,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_export_tariff_4:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2731,7 +2059,7 @@ 'state': '8765.444', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2763,7 +2091,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2796,7 +2124,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2811,7 +2139,7 @@ 'state': '13779.338', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_1:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2843,7 +2171,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_1:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2876,7 +2204,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_1:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2891,7 +2219,7 @@ 'state': '10830.511', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_2:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2923,7 +2251,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_2:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2956,7 +2284,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_2:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2971,7 +2299,7 @@ 'state': '2948.827', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_3:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3003,7 +2331,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_3:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3036,7 +2364,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_3:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -3051,7 +2379,7 @@ 'state': '2948.827', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_4:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3083,7 +2411,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_4:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3116,7 +2444,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_energy_import_tariff_4:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -3131,7 +2459,7 @@ 'state': '2948.827', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas:device-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3163,7 +2491,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_gas:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3196,7 +2524,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_gas:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'gas', @@ -3211,7 +2539,7 @@ 'state': '1122.333', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_gas] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3243,1159 +2571,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_export', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power export', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_export', - 'last_changed': , - 'last_updated': , - 'state': '13086.777', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_export_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power export tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_export_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power export tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_export_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '4321.333', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_1] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_2:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_2:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_export_tariff_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power export tariff 2', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_export_t2_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_2:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power export tariff 2', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_export_tariff_2', - 'last_changed': , - 'last_updated': , - 'state': '8765.444', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_2] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_3:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_3:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_export_tariff_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power export tariff 3', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_export_t3_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_3:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power export tariff 3', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_export_tariff_3', - 'last_changed': , - 'last_updated': , - 'state': '8765.444', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_3] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_4:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_4:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_export_tariff_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power export tariff 4', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_export_t4_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_4:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power export tariff 4', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_export_tariff_4', - 'last_changed': , - 'last_updated': , - 'state': '8765.444', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_export_tariff_4] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_import', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power import', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power import', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_import', - 'last_changed': , - 'last_updated': , - 'state': '13779.338', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_import_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power import tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_import_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power import tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_import_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '10830.511', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_1] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_2:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_2:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_import_tariff_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power import tariff 2', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_import_t2_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_2:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power import tariff 2', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_import_tariff_2', - 'last_changed': , - 'last_updated': , - 'state': '2948.827', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_2] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_3:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_3:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_import_tariff_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power import tariff 3', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_import_t3_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_3:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power import tariff 3', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_import_tariff_3', - 'last_changed': , - 'last_updated': , - 'state': '2948.827', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_3] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_4:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_4:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_power_import_tariff_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total power import tariff 4', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_power_import_t4_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_4:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total power import tariff 4', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_power_import_tariff_4', - 'last_changed': , - 'last_updated': , - 'state': '2948.827', - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_power_import_tariff_4] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_water_usage:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_water_usage:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_water_usage:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4428,7 +2604,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_water_usage:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_water_usage:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', @@ -4444,7 +2620,7 @@ 'state': '1234.567', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_total_water_usage] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4476,39 +2652,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_1:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4539,7 +2683,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_1:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 1', @@ -4552,7 +2696,7 @@ 'state': '1', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_1] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4584,39 +2728,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_2:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4647,7 +2759,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_2:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 2', @@ -4660,7 +2772,7 @@ 'state': '2', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_2] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4692,39 +2804,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_3:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4755,7 +2835,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_3:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 3', @@ -4768,7 +2848,7 @@ 'state': '3', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_sags_detected_phase_3] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4800,39 +2880,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_1:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4863,7 +2911,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_1:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 1', @@ -4876,7 +2924,7 @@ 'state': '4', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_1] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4908,39 +2956,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_2:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4971,7 +2987,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_2:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 2', @@ -4984,7 +3000,7 @@ 'state': '5', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_2] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -5016,39 +3032,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_3:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5079,7 +3063,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_3:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 3', @@ -5092,7 +3076,7 @@ 'state': '6', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_voltage_swells_detected_phase_3] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -5124,39 +3108,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_ssid:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_ssid:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5187,7 +3139,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_ssid:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', @@ -5200,7 +3152,7 @@ 'state': 'My Wi-Fi', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_ssid] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -5232,39 +3184,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_strength:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_strength:entity-registry] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5297,7 +3217,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_strength:state] +# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', @@ -5312,35 +3232,3 @@ 'state': '100', }) # --- -# name: test_sensors_p1_meter[device-HWE-P1.json-data-HWE-P1.json-entity_ids0][sensor.device_wi_fi_strength] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-P1', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '4.19', - 'via_device_id': None, - }) -# --- diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index f6bd7c1d9eb..d38fab029d3 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities[switch.device-state_set-power_on-device-HWE-SKT.json] +# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -12,7 +12,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[switch.device-state_set-power_on-device-HWE-SKT.json].1 +# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -43,7 +43,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device-state_set-power_on-device-HWE-SKT.json].2 +# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -75,7 +75,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-device-HWE-SKT.json] +# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -88,7 +88,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-device-HWE-SKT.json].1 +# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-device-HWE-SKT.json].2 +# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -151,7 +151,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-device-HWE-SKT.json] +# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -164,7 +164,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-device-HWE-SKT.json].1 +# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -195,7 +195,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-device-HWE-SKT.json].2 +# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index 97989e17a6e..d87cde87616 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -17,7 +17,7 @@ pytestmark = [ ] -@pytest.mark.parametrize("device_fixture", ["device-sdm230.json"]) +@pytest.mark.parametrize("device_fixture", ["SDM230"]) async def test_identify_button_entity_not_loaded_when_not_available( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 71593c69c64..127ffbdc0f5 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -10,9 +10,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -@pytest.mark.parametrize( - "device_fixture", ["device-HWE-P1.json", "device-HWE-SKT.json"] -) +@pytest.mark.parametrize("device_fixture", ["HWE-P1", "HWE-SKT", "SDM230"]) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 4ae5b2ef22b..a3f4da0fdba 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -18,7 +18,7 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize("device_fixture", ["device-HWE-SKT.json"]) +@pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) async def test_number_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index de1a2e545de..971047a14ff 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -21,11 +21,10 @@ pytestmark = [ @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "data_fixture", "entity_ids"), + ("device_fixture", "entity_ids"), [ ( - "device-HWE-P1.json", - "data-HWE-P1.json", + "HWE-P1", [ "sensor.device_dsmr_version", "sensor.device_smart_meter_model", @@ -116,7 +115,7 @@ async def test_disabled_by_default_sensors( assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION -@pytest.mark.parametrize("data_fixture", ["data-HWE-P1-unused-exports.json"]) +@pytest.mark.parametrize("device_fixture", ["HWE-P1-unused-exports"]) @pytest.mark.parametrize( "entity_id", [ diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 4c5e1dda6a0..c63c1c864af 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -26,7 +26,7 @@ pytestmark = [ ] -@pytest.mark.parametrize("device_fixture", ["device-HWE-SKT.json"]) +@pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) @pytest.mark.parametrize( ("entity_id", "method", "parameter"), [ @@ -119,7 +119,7 @@ async def test_switch_entities( ) -@pytest.mark.parametrize("device_fixture", ["device-HWE-SKT.json"]) +@pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) @pytest.mark.parametrize("exception", [RequestError, DisabledError, UnsupportedError]) @pytest.mark.parametrize( ("entity_id", "method"), From 216349de25c284ccd709686e0e2eb9d748c01a6c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 4 Nov 2023 14:00:01 +0100 Subject: [PATCH 210/982] Address late review for NINA (#103367) Set device entry type --- homeassistant/components/nina/binary_sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 6310f43ca54..92c7d16dc84 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -78,6 +78,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer="NINA", + entry_type=DeviceEntryType.SERVICE, ) @property From 1517081a2dc376c7ffb0a769b0e74f2925f52d11 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 4 Nov 2023 14:47:06 +0100 Subject: [PATCH 211/982] Set device entry type for dwd_weather_warnings (#103370) * Set device entry type for dwd_weather_warnings * Set integration type to service --- homeassistant/components/dwd_weather_warnings/manifest.json | 1 + homeassistant/components/dwd_weather_warnings/sensor.py | 6 ++++-- homeassistant/generated/integrations.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index dab3a39c10f..1a497b64ae3 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@runningman84", "@stephan192", "@andarotajo"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], "requirements": ["dwdwfsapi==1.0.6"] diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 78154e9e4f4..6274bb4d529 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -146,7 +146,9 @@ class DwdWeatherWarningsSensor( self._attr_unique_id = f"{entry.unique_id}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, name=f"{DEFAULT_NAME} {entry.title}" + identifiers={(DOMAIN, entry.entry_id)}, + name=f"{DEFAULT_NAME} {entry.title}", + entry_type=DeviceEntryType.SERVICE, ) self.api = coordinator.api diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index be9d1f1bf5d..dca042bed20 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1270,7 +1270,7 @@ }, "dwd_weather_warnings": { "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, From c561372f302e64efe415615adda79942feca1d07 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 4 Nov 2023 15:14:03 +0100 Subject: [PATCH 212/982] Remove deprecated /config/server_control redirect (#103331) --- homeassistant/components/frontend/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2ec991750f0..14892c35aac 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -385,9 +385,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - # Can be removed in 2023 - hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") - # Shopping list panel was replaced by todo panel in 2023.11 hass.http.register_redirect("/shopping-list", "/todo") From 07f03d9ec93d0a37be6566530a0c0ca67b5f33d8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Nov 2023 15:40:50 +0100 Subject: [PATCH 213/982] Set suggested display precision for HomeWizard Energy power sensors (#103369) --- homeassistant/components/homewizard/sensor.py | 4 ++++ .../components/homewizard/snapshots/test_sensor.ambr | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index c98a8fa05b4..84aa58f2d27 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -208,6 +208,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, has_fn=lambda data: data.active_power_w is not None, value_fn=lambda data: data.active_power_w, ), @@ -217,6 +218,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, has_fn=lambda data: data.active_power_l1_w is not None, value_fn=lambda data: data.active_power_l1_w, ), @@ -226,6 +228,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, has_fn=lambda data: data.active_power_l2_w is not None, value_fn=lambda data: data.active_power_l2_w, ), @@ -235,6 +238,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, has_fn=lambda data: data.active_power_l3_w is not None, value_fn=lambda data: data.active_power_l3_w, ), diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 930652ed513..176e10d219c 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -449,6 +449,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -529,6 +532,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -609,6 +615,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -689,6 +698,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, From 2d3318e76770f9db623f7b7ffb4a37eadc71ad46 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 4 Nov 2023 16:17:31 +0100 Subject: [PATCH 214/982] Remove platform YAML from Command line (#103202) --- .../components/command_line/binary_sensor.py | 39 +--------- .../components/command_line/cover.py | 58 ++------------ .../components/command_line/notify.py | 40 ++-------- .../components/command_line/sensor.py | 45 ++--------- .../components/command_line/strings.json | 6 -- .../components/command_line/switch.py | 65 ++-------------- .../command_line/test_binary_sensor.py | 29 ------- tests/components/command_line/test_cover.py | 69 ---------------- tests/components/command_line/test_notify.py | 20 ----- tests/components/command_line/test_sensor.py | 30 ------- tests/components/command_line/test_switch.py | 78 +++---------------- 11 files changed, 36 insertions(+), 443 deletions(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 3ccd0bd1503..f559812207f 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -3,13 +3,9 @@ from __future__ import annotations import asyncio from datetime import timedelta - -import voluptuous as vol +from typing import cast from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, - DOMAIN as BINARY_SENSOR_DOMAIN, - PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, ) @@ -25,16 +21,14 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER +from .const import CONF_COMMAND_TIMEOUT, LOGGER from .sensor import CommandSensorData DEFAULT_NAME = "Binary Command Sensor" @@ -44,20 +38,6 @@ DEFAULT_PAYLOAD_OFF = "OFF" SCAN_INTERVAL = timedelta(seconds=60) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, - vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -66,19 +46,8 @@ async def async_setup_platform( ) -> None: """Set up the Command line Binary Sensor.""" - if binary_sensor_config := config: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_binary_sensor", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": BINARY_SENSOR_DOMAIN}, - ) - if discovery_info: - binary_sensor_config = discovery_info + discovery_info = cast(DiscoveryInfoType, discovery_info) + binary_sensor_config = discovery_info name: str = binary_sensor_config.get(CONF_NAME, DEFAULT_NAME) command: str = binary_sensor_config[CONF_COMMAND] diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 2aa67cec641..6b413712ed7 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -3,59 +3,32 @@ from __future__ import annotations import asyncio from datetime import timedelta -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -import voluptuous as vol - -from homeassistant.components.cover import ( - DOMAIN as COVER_DOMAIN, - PLATFORM_SCHEMA, - CoverEntity, -) +from homeassistant.components.cover import CoverEntity from homeassistant.const import ( CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, CONF_COMMAND_STATE, CONF_COMMAND_STOP, - CONF_COVERS, - CONF_FRIENDLY_NAME, CONF_NAME, CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER +from .const import CONF_COMMAND_TIMEOUT, LOGGER from .utils import call_shell_with_timeout, check_output_or_log SCAN_INTERVAL = timedelta(seconds=15) -COVER_SCHEMA = vol.Schema( - { - vol.Optional(CONF_COMMAND_CLOSE, default="true"): cv.string, - vol.Optional(CONF_COMMAND_OPEN, default="true"): cv.string, - vol.Optional(CONF_COMMAND_STATE): cv.string, - vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)} -) - async def async_setup_platform( hass: HomeAssistant, @@ -66,31 +39,14 @@ async def async_setup_platform( """Set up cover controlled by shell commands.""" covers = [] - if discovery_info: - entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} - else: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_cover", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": COVER_DOMAIN}, - ) - entities = config.get(CONF_COVERS, {}) + discovery_info = cast(DiscoveryInfoType, discovery_info) + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} for device_name, device_config in entities.items(): value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass - if name := device_config.get( - CONF_FRIENDLY_NAME - ): # Backward compatibility. Can be removed after deprecation - device_config[CONF_NAME] = name - trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, device_name), hass), @@ -109,10 +65,6 @@ async def async_setup_platform( ) ) - if not covers: - LOGGER.error("No covers added") - return - async_add_entities(covers) diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index d00926eb0ee..f61e9959af9 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -3,34 +3,18 @@ from __future__ import annotations import logging import subprocess -from typing import Any +from typing import Any, cast -import voluptuous as vol - -from homeassistant.components.notify import ( - DOMAIN as NOTIFY_DOMAIN, - PLATFORM_SCHEMA, - BaseNotificationService, -) -from homeassistant.const import CONF_COMMAND, CONF_NAME +from homeassistant.components.notify import BaseNotificationService +from homeassistant.const import CONF_COMMAND from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) - def get_service( hass: HomeAssistant, @@ -38,19 +22,9 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> CommandLineNotificationService: """Get the Command Line notification service.""" - if notify_config := config: - create_issue( - hass, - DOMAIN, - "deprecated_yaml_notify", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": NOTIFY_DOMAIN}, - ) - if discovery_info: - notify_config = discovery_info + + discovery_info = cast(DiscoveryInfoType, discovery_info) + notify_config = discovery_info command: str = notify_config[CONF_COMMAND] timeout: int = notify_config[CONF_COMMAND_TIMEOUT] diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index a617d348c8d..99390e77357 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -7,16 +7,7 @@ from datetime import timedelta import json from typing import Any, cast -import voluptuous as vol - -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - DEVICE_CLASSES_SCHEMA, - DOMAIN as SENSOR_DOMAIN, - PLATFORM_SCHEMA, - STATE_CLASSES_SCHEMA, - SensorDeviceClass, -) +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_COMMAND, @@ -30,10 +21,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, @@ -43,7 +32,7 @@ from homeassistant.helpers.trigger_template_entity import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER +from .const import CONF_COMMAND_TIMEOUT, LOGGER from .utils import check_output_or_log CONF_JSON_ATTRIBUTES = "json_attributes" @@ -62,20 +51,6 @@ TRIGGER_ENTITY_OPTIONS = ( SCAN_INTERVAL = timedelta(seconds=60) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - } -) - async def async_setup_platform( hass: HomeAssistant, @@ -84,19 +59,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command Sensor.""" - if sensor_config := config: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_sensor", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": SENSOR_DOMAIN}, - ) - if discovery_info: - sensor_config = discovery_info + + discovery_info = cast(DiscoveryInfoType, discovery_info) + sensor_config = discovery_info name: str = sensor_config[CONF_NAME] command: str = sensor_config[CONF_COMMAND] diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json index 9fc0de2ab28..377ed7927aa 100644 --- a/homeassistant/components/command_line/strings.json +++ b/homeassistant/components/command_line/strings.json @@ -1,10 +1,4 @@ { - "issues": { - "deprecated_platform_yaml": { - "title": "Command Line YAML configuration has moved", - "description": "Configuring Command Line `{platform}` using YAML has moved.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to fix this issue." - } - }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 004a65643bb..8d30de310ef 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -3,61 +3,32 @@ from __future__ import annotations import asyncio from datetime import timedelta -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -import voluptuous as vol - -from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, - ENTITY_ID_FORMAT, - PLATFORM_SCHEMA, - SwitchEntity, -) +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_COMMAND_STATE, - CONF_FRIENDLY_NAME, CONF_ICON, - CONF_ICON_TEMPLATE, CONF_NAME, CONF_SCAN_INTERVAL, - CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER +from .const import CONF_COMMAND_TIMEOUT, LOGGER from .utils import call_shell_with_timeout, check_output_or_log SCAN_INTERVAL = timedelta(seconds=30) -SWITCH_SCHEMA = vol.Schema( - { - vol.Optional(CONF_COMMAND_OFF, default="true"): cv.string, - vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, - vol.Optional(CONF_COMMAND_STATE): cv.string, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_ICON_TEMPLATE): cv.template, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA)} -) - async def async_setup_platform( hass: HomeAssistant, @@ -67,34 +38,12 @@ async def async_setup_platform( ) -> None: """Find and return switches controlled by shell commands.""" - if discovery_info: - entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} - else: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_switch", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": SWITCH_DOMAIN}, - ) - entities = config.get(CONF_SWITCHES, {}) + discovery_info = cast(DiscoveryInfoType, discovery_info) + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} switches = [] for object_id, device_config in entities.items(): - if name := device_config.get( - CONF_FRIENDLY_NAME - ): # Backward compatibility. Can be removed after deprecation - device_config[CONF_NAME] = name - - if icon := device_config.get( - CONF_ICON_TEMPLATE - ): # Backward compatibility. Can be removed after deprecation - device_config[CONF_ICON] = icon - trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, object_id), hass), @@ -119,10 +68,6 @@ async def async_setup_platform( ) ) - if not switches: - LOGGER.error("No switches added") - return - async_add_entities(switches) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 360c78dd5a7..eaa7061551a 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -9,7 +9,6 @@ from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.command_line.binary_sensor import CommandBinarySensor from homeassistant.components.command_line.const import DOMAIN from homeassistant.components.homeassistant import ( @@ -19,39 +18,11 @@ from homeassistant.components.homeassistant import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: - """Test sensor setup.""" - assert await setup.async_setup_component( - hass, - BINARY_SENSOR_DOMAIN, - { - BINARY_SENSOR_DOMAIN: { - "platform": "command_line", - "name": "Test", - "command": "echo 1", - "payload_on": "1", - "payload_off": "0", - } - }, - ) - await hass.async_block_till_done() - - entity_state = hass.states.get("binary_sensor.test") - assert entity_state - assert entity_state.state == STATE_ON - assert entity_state.name == "Test" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_binary_sensor") - assert issue.translation_key == "deprecated_platform_yaml" - - @pytest.mark.parametrize( "get_config", [ diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 64fa2a60719..e6e428388f4 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -25,80 +25,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -async def test_no_covers_platform_yaml( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant -) -> None: - """Test that the cover does not polls when there's no state command.""" - - with patch( - "homeassistant.components.command_line.utils.subprocess.check_output", - return_value=b"50\n", - ): - assert await setup.async_setup_component( - hass, - COVER_DOMAIN, - { - COVER_DOMAIN: [ - {"platform": "command_line", "covers": {}}, - ] - }, - ) - await hass.async_block_till_done() - assert "No covers added" in caplog.text - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_cover") - assert issue.translation_key == "deprecated_platform_yaml" - - -async def test_state_value_platform_yaml(hass: HomeAssistant) -> None: - """Test with state value.""" - with tempfile.TemporaryDirectory() as tempdirname: - path = os.path.join(tempdirname, "cover_status") - assert await setup.async_setup_component( - hass, - COVER_DOMAIN, - { - COVER_DOMAIN: [ - { - "platform": "command_line", - "covers": { - "test": { - "command_state": f"cat {path}", - "command_open": f"echo 1 > {path}", - "command_close": f"echo 1 > {path}", - "command_stop": f"echo 0 > {path}", - "value_template": "{{ value }}", - "friendly_name": "Test", - }, - }, - }, - ] - }, - ) - await hass.async_block_till_done() - - entity_state = hass.states.get("cover.test") - assert entity_state - assert entity_state.state == "unknown" - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test"}, - blocking=True, - ) - entity_state = hass.states.get("cover.test") - assert entity_state - assert entity_state.state == "open" - - async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> None: """Test that the cover does not polls when there's no state command.""" diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index a17b1ec33e1..96ad5ce2ee8 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -12,26 +12,6 @@ from homeassistant import setup from homeassistant.components.command_line import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir - - -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: - """Test sensor setup.""" - assert await setup.async_setup_component( - hass, - NOTIFY_DOMAIN, - { - NOTIFY_DOMAIN: [ - {"platform": "command_line", "name": "Test1", "command": "exit 0"}, - ] - }, - ) - await hass.async_block_till_done() - assert hass.services.has_service(NOTIFY_DOMAIN, "test1") - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_notify") - assert issue.translation_key == "deprecated_platform_yaml" @pytest.mark.parametrize( diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 388d0345cad..9f28b8cc6d0 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -16,44 +16,14 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: - """Test sensor setup.""" - assert await setup.async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: [ - { - "platform": "command_line", - "name": "Test", - "command": "echo 5", - "unit_of_measurement": "in", - }, - ] - }, - ) - await hass.async_block_till_done() - entity_state = hass.states.get("sensor.test") - assert entity_state - assert entity_state.state == "5" - assert entity_state.name == "Test" - assert entity_state.attributes["unit_of_measurement"] == "in" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_sensor") - assert issue.translation_key == "deprecated_platform_yaml" - - @pytest.mark.parametrize( "get_config", [ diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 09e8c47d708..f1f4096fa91 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -28,34 +28,27 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -async def test_state_platform_yaml(hass: HomeAssistant) -> None: +async def test_state_integration_yaml(hass: HomeAssistant) -> None: """Test with none state.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - assert await setup.async_setup_component( + await setup.async_setup_component( hass, - SWITCH_DOMAIN, + DOMAIN, { - SWITCH_DOMAIN: [ + "command_line": [ { - "platform": "command_line", - "switches": { - "test": { - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - "friendly_name": "Test", - "icon_template": ( - '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}' - ), - } - }, - }, + "switch": { + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "name": "Test", + } + } ] }, ) @@ -87,36 +80,6 @@ async def test_state_platform_yaml(hass: HomeAssistant) -> None: assert entity_state assert entity_state.state == STATE_OFF - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_switch") - assert issue.translation_key == "deprecated_platform_yaml" - - -async def test_state_integration_yaml(hass: HomeAssistant) -> None: - """Test with none state.""" - with tempfile.TemporaryDirectory() as tempdirname: - path = os.path.join(tempdirname, "switch_status") - await setup.async_setup_component( - hass, - DOMAIN, - { - "command_line": [ - { - "switch": { - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - "name": "Test", - } - } - ] - }, - ) - await hass.async_block_till_done() - - entity_state = hass.states.get("switch.test") - assert entity_state - assert entity_state.state == STATE_OFF - async def test_state_value(hass: HomeAssistant) -> None: """Test with state value.""" @@ -487,27 +450,6 @@ async def test_switch_command_state_value_exceptions( assert "Error trying to exec command" in caplog.text -async def test_no_switches_platform_yaml( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant -) -> None: - """Test with no switches.""" - - assert await setup.async_setup_component( - hass, - SWITCH_DOMAIN, - { - SWITCH_DOMAIN: [ - { - "platform": "command_line", - "switches": {}, - }, - ] - }, - ) - await hass.async_block_till_done() - assert "No switches" in caplog.text - - async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: From 72c02d4d63b94d3b10201f46be065191d17c7d32 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 4 Nov 2023 16:17:51 +0100 Subject: [PATCH 215/982] Remove counter configure service (#103204) * Remove counter configure service after deprecation * reproduce state --- homeassistant/components/counter/__init__.py | 32 ---- .../components/counter/reproduce_state.py | 15 +- homeassistant/components/counter/strings.json | 13 -- tests/components/counter/test_init.py | 144 +----------------- .../counter/test_reproduce_state.py | 9 +- 5 files changed, 7 insertions(+), 206 deletions(-) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index f946f29bdaa..42676498c9f 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -18,7 +18,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -44,7 +43,6 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" SERVICE_DECREMENT = "decrement" SERVICE_INCREMENT = "increment" SERVICE_RESET = "reset" -SERVICE_CONFIGURE = "configure" SERVICE_SET_VALUE = "set_value" STORAGE_KEY = DOMAIN @@ -131,17 +129,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: {vol.Required(VALUE): cv.positive_int}, "async_set_value", ) - component.async_register_entity_service( - SERVICE_CONFIGURE, - { - vol.Optional(ATTR_MINIMUM): vol.Any(None, vol.Coerce(int)), - vol.Optional(ATTR_MAXIMUM): vol.Any(None, vol.Coerce(int)), - vol.Optional(ATTR_STEP): cv.positive_int, - vol.Optional(ATTR_INITIAL): cv.positive_int, - vol.Optional(VALUE): cv.positive_int, - }, - "async_configure", - ) return True @@ -285,25 +272,6 @@ class Counter(collection.CollectionEntity, RestoreEntity): self._state = value self.async_write_ha_state() - @callback - def async_configure(self, **kwargs) -> None: - """Change the counter's settings with a service.""" - async_create_issue( - self.hass, - DOMAIN, - "deprecated_configure_service", - breaks_in_ha_version="2023.12.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_configure_service", - ) - - new_state = kwargs.pop(VALUE, self._state) - self._config = {**self._config, **kwargs} - self._state = self.compute_next_state(new_state) - self.async_write_ha_state() - async def async_update_config(self, config: ConfigType) -> None: """Change the counter's settings WS CRUD.""" self._config = config diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py index 2029321c430..2308e0fb07a 100644 --- a/homeassistant/components/counter/reproduce_state.py +++ b/homeassistant/components/counter/reproduce_state.py @@ -9,15 +9,7 @@ from typing import Any from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import Context, HomeAssistant, State -from . import ( - ATTR_INITIAL, - ATTR_MAXIMUM, - ATTR_MINIMUM, - ATTR_STEP, - DOMAIN, - SERVICE_CONFIGURE, - VALUE, -) +from . import ATTR_MAXIMUM, ATTR_MINIMUM, ATTR_STEP, DOMAIN, SERVICE_SET_VALUE, VALUE _LOGGER = logging.getLogger(__name__) @@ -43,7 +35,6 @@ async def _async_reproduce_state( # Return if we are already at the right state. if ( cur_state.state == state.state - and cur_state.attributes.get(ATTR_INITIAL) == state.attributes.get(ATTR_INITIAL) and cur_state.attributes.get(ATTR_MAXIMUM) == state.attributes.get(ATTR_MAXIMUM) and cur_state.attributes.get(ATTR_MINIMUM) == state.attributes.get(ATTR_MINIMUM) and cur_state.attributes.get(ATTR_STEP) == state.attributes.get(ATTR_STEP) @@ -51,9 +42,7 @@ async def _async_reproduce_state( return service_data = {ATTR_ENTITY_ID: state.entity_id, VALUE: state.state} - service = SERVICE_CONFIGURE - if ATTR_INITIAL in state.attributes: - service_data[ATTR_INITIAL] = state.attributes[ATTR_INITIAL] + service = SERVICE_SET_VALUE if ATTR_MAXIMUM in state.attributes: service_data[ATTR_MAXIMUM] = state.attributes[ATTR_MAXIMUM] if ATTR_MINIMUM in state.attributes: diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 53c87349836..fb1f6467f4a 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -26,19 +26,6 @@ } } }, - "issues": { - "deprecated_configure_service": { - "title": "The counter configure service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::counter::issues::deprecated_configure_service::title%]", - "description": "The counter service `counter.configure` is being removed and use of it has been detected. If you want to change the current value of a counter, use the new `counter.set_value` service instead.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." - } - } - } - } - }, "services": { "decrement": { "name": "Decrement", diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 097102a341e..12750363469 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -24,7 +24,7 @@ from homeassistant.components.counter import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import async_decrement, async_increment, async_reset @@ -432,148 +432,6 @@ async def test_counter_max(hass: HomeAssistant, hass_admin_user: MockUser) -> No assert state2.state == "-1" -async def test_configure( - hass: HomeAssistant, hass_admin_user: MockUser, issue_registry: ir.IssueRegistry -) -> None: - """Test that setting values through configure works.""" - assert await async_setup_component( - hass, "counter", {"counter": {"test": {"maximum": "10", "initial": "10"}}} - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "10" - assert state.attributes.get("maximum") == 10 - - # update max - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "maximum": 0}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "0" - assert state.attributes.get("maximum") == 0 - - # Ensure an issue is raised for the use of this deprecated service - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecated_configure_service" - ) - - # disable max - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "maximum": None}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "0" - assert state.attributes.get("maximum") is None - - # update min - assert state.attributes.get("minimum") is None - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "minimum": 5}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "5" - assert state.attributes.get("minimum") == 5 - - # disable min - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "minimum": None}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "5" - assert state.attributes.get("minimum") is None - - # update step - assert state.attributes.get("step") == 1 - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "step": 3}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "5" - assert state.attributes.get("step") == 3 - - # update value - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "value": 6}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "6" - - # update initial - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "initial": 5}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "6" - assert state.attributes.get("initial") == 5 - - # update all - await hass.services.async_call( - "counter", - "configure", - { - "entity_id": state.entity_id, - "step": 5, - "minimum": 0, - "maximum": 9, - "value": 5, - "initial": 6, - }, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "5" - assert state.attributes.get("step") == 5 - assert state.attributes.get("minimum") == 0 - assert state.attributes.get("maximum") == 9 - assert state.attributes.get("initial") == 6 - - async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: """Test set up from storage.""" assert await storage_setup() diff --git a/tests/components/counter/test_reproduce_state.py b/tests/components/counter/test_reproduce_state.py index dfd4f95bec2..44d0eca4d72 100644 --- a/tests/components/counter/test_reproduce_state.py +++ b/tests/components/counter/test_reproduce_state.py @@ -15,10 +15,10 @@ async def test_reproducing_states( hass.states.async_set( "counter.entity_attr", "8", - {"initial": 12, "minimum": 5, "maximum": 15, "step": 3}, + {"minimum": 5, "maximum": 15, "step": 3}, ) - configure_calls = async_mock_service(hass, "counter", "configure") + configure_calls = async_mock_service(hass, "counter", "set_value") # These calls should do nothing as entities already in desired state await async_reproduce_state( @@ -28,7 +28,7 @@ async def test_reproducing_states( State( "counter.entity_attr", "8", - {"initial": 12, "minimum": 5, "maximum": 15, "step": 3}, + {"minimum": 5, "maximum": 15, "step": 3}, ), ], ) @@ -49,7 +49,7 @@ async def test_reproducing_states( State( "counter.entity_attr", "7", - {"initial": 10, "minimum": 3, "maximum": 21, "step": 5}, + {"minimum": 3, "maximum": 21, "step": 5}, ), # Should not raise State("counter.non_existing", "6"), @@ -61,7 +61,6 @@ async def test_reproducing_states( { "entity_id": "counter.entity_attr", "value": "7", - "initial": 10, "minimum": 3, "maximum": 21, "step": 5, From 26e1285f345a65b4c0a3091a8d752559d4a97839 Mon Sep 17 00:00:00 2001 From: mkmer Date: Sat, 4 Nov 2023 11:21:10 -0400 Subject: [PATCH 216/982] Address late review for blink (#103376) * use self.coordinator * Dont store coordinator * revert unintended sensor change * revert remove * indention error * revert * Revert more --- homeassistant/components/blink/alarm_control_panel.py | 7 +++---- homeassistant/components/blink/binary_sensor.py | 1 + homeassistant/components/blink/camera.py | 8 ++++---- homeassistant/components/blink/sensor.py | 1 + 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index d1fcb889fb8..c19b07c5874 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -57,8 +57,7 @@ class BlinkSyncModuleHA( ) -> None: """Initialize the alarm control panel.""" super().__init__(coordinator) - self.api: Blink = coordinator.api - self._coordinator = coordinator + self.api: Blink = self.coordinator.api self.sync = sync self._attr_unique_id: str = sync.serial self._attr_device_info = DeviceInfo( @@ -94,7 +93,7 @@ class BlinkSyncModuleHA( except asyncio.TimeoutError as er: raise HomeAssistantError("Blink failed to disarm camera") from er - await self._coordinator.async_refresh() + await self.coordinator.async_refresh() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" @@ -104,5 +103,5 @@ class BlinkSyncModuleHA( except asyncio.TimeoutError as er: raise HomeAssistantError("Blink failed to arm camera away") from er - await self._coordinator.async_refresh() + await self.coordinator.async_refresh() self.async_write_ha_state() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 47b45e2f4ec..9400e79838b 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -47,6 +47,7 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the blink binary sensors.""" + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index c967ff59c8c..f507364f17f 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -32,6 +32,7 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a Blink Camera.""" + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ BlinkCamera(coordinator, name, camera) @@ -54,7 +55,6 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Initialize a camera.""" super().__init__(coordinator) Camera.__init__(self) - self._coordinator = coordinator self._camera = camera self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( @@ -80,7 +80,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): raise HomeAssistantError("Blink failed to arm camera") from er self._camera.motion_enabled = True - await self._coordinator.async_refresh() + await self.coordinator.async_refresh() async def async_disable_motion_detection(self) -> None: """Disable motion detection for the camera.""" @@ -90,7 +90,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): raise HomeAssistantError("Blink failed to disarm camera") from er self._camera.motion_enabled = False - await self._coordinator.async_refresh() + await self.coordinator.async_refresh() @property def motion_detection_enabled(self) -> bool: @@ -106,7 +106,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Trigger camera to take a snapshot.""" with contextlib.suppress(asyncio.TimeoutError): await self._camera.snap_picture() - await self._coordinator.api.refresh() + await self.coordinator.api.refresh() self.async_write_ha_state() def camera_image( diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 064ad9d04f2..74db76c421e 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -48,6 +48,7 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize a Blink sensor.""" + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ BlinkSensor(coordinator, camera, description) From 7671ab0bb7a025d8ba03be4aec7ad2bdf950d164 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 4 Nov 2023 16:41:59 +0100 Subject: [PATCH 217/982] Remove platform yaml from myStrom (#103378) --- .../components/mystrom/config_flow.py | 4 -- homeassistant/components/mystrom/light.py | 46 +------------------ homeassistant/components/mystrom/switch.py | 46 ++----------------- tests/components/mystrom/test_config_flow.py | 21 --------- 4 files changed, 5 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/mystrom/config_flow.py b/homeassistant/components/mystrom/config_flow.py index 3dc334d8252..6b2fe85bfe8 100644 --- a/homeassistant/components/mystrom/config_flow.py +++ b/homeassistant/components/mystrom/config_flow.py @@ -31,10 +31,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle import from config.""" - return await self.async_step_user(import_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 6a6e7efa1b3..ce9357d23f7 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -5,25 +5,19 @@ import logging from typing import Any from pymystrom.exceptions import MyStromConnectionError -import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, - PLATFORM_SCHEMA, ColorMode, LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, MANUFACTURER @@ -34,14 +28,6 @@ DEFAULT_NAME = "myStrom bulb" EFFECT_RAINBOW = "rainbow" EFFECT_SUNRISE = "sunrise" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -52,34 +38,6 @@ async def async_setup_entry( async_add_entities([MyStromLight(device, entry.title, info["mac"])]) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the myStrom light integration.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "myStrom", - }, - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class MyStromLight(LightEntity): """Representation of the myStrom WiFi bulb.""" diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 262ee54101b..8f459e6801e 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -5,17 +5,12 @@ import logging from typing import Any from pymystrom.exceptions import MyStromConnectionError -import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, MANUFACTURER @@ -23,13 +18,6 @@ DEFAULT_NAME = "myStrom Switch" _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -39,34 +27,6 @@ async def async_setup_entry( async_add_entities([MyStromSwitch(device, entry.title)]) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the myStrom switch/plug integration.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "myStrom", - }, - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class MyStromSwitch(SwitchEntity): """Representation of a myStrom switch/plug.""" diff --git a/tests/components/mystrom/test_config_flow.py b/tests/components/mystrom/test_config_flow.py index 97823681b8e..9459519de75 100644 --- a/tests/components/mystrom/test_config_flow.py +++ b/tests/components/mystrom/test_config_flow.py @@ -6,7 +6,6 @@ import pytest from homeassistant import config_entries from homeassistant.components.mystrom.const import DOMAIN -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -72,26 +71,6 @@ async def test_form_duplicates( mock_session.assert_called_once() -async def test_step_import(hass: HomeAssistant) -> None: - """Test the import step.""" - conf = { - CONF_HOST: "1.1.1.1", - } - with patch("pymystrom.switch.MyStromSwitch.get_state"), patch( - "pymystrom.get_device_info", - return_value={"type": 101, "mac": DEVICE_MAC}, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "myStrom Device" - assert result["data"] == { - CONF_HOST: "1.1.1.1", - } - - async def test_wong_answer_from_device(hass: HomeAssistant) -> None: """Test handling of wrong answers from the device.""" From 22306bd3093096ff48d3045531223cd92641cb06 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 4 Nov 2023 11:54:00 -0400 Subject: [PATCH 218/982] Add diagnostics support to Schlage (#103347) Co-authored-by: J. Nick Koston --- .../components/schlage/diagnostics.py | 23 +++++++++++++++++++ .../components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/schlage/test_diagnostics.py | 23 +++++++++++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/schlage/diagnostics.py create mode 100644 tests/components/schlage/test_diagnostics.py diff --git a/homeassistant/components/schlage/diagnostics.py b/homeassistant/components/schlage/diagnostics.py new file mode 100644 index 00000000000..af1bf311676 --- /dev/null +++ b/homeassistant/components/schlage/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics support for Schlage.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import SchlageDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + # NOTE: Schlage diagnostics are already redacted. + return { + "locks": [ld.lock.get_diagnostics() for ld in coordinator.data.locks.values()] + } diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index f474f739904..1eb7cb2ab0f 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.10.0"] + "requirements": ["pyschlage==2023.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3cb3e06c7d9..56f7aaf3511 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2007,7 +2007,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.10.0 +pyschlage==2023.11.0 # homeassistant.components.sensibo pysensibo==1.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf55bc4aeee..c7e0dc73ff4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1514,7 +1514,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.10.0 +pyschlage==2023.11.0 # homeassistant.components.sensibo pysensibo==1.0.36 diff --git a/tests/components/schlage/test_diagnostics.py b/tests/components/schlage/test_diagnostics.py new file mode 100644 index 00000000000..15b2316bf38 --- /dev/null +++ b/tests/components/schlage/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Test Schlage diagnostics.""" + +from unittest.mock import Mock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_added_config_entry: MockConfigEntry, + mock_lock: Mock, +) -> None: + """Test Schlage diagnostics.""" + mock_lock.get_diagnostics.return_value = {"foo": "bar"} + diag = await get_diagnostics_for_config_entry( + hass, hass_client, mock_added_config_entry + ) + assert diag == {"locks": [{"foo": "bar"}]} From 8b30a901ddb9f3b8a13954b27e86db2ecf3a6962 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 10:54:22 -0500 Subject: [PATCH 219/982] Remove unreachable code in logbook (#103309) --- homeassistant/components/logbook/models.py | 12 +++--------- tests/components/logbook/test_models.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 tests/components/logbook/test_models.py diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 82c05e612e3..6939904f520 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -42,9 +42,6 @@ class LazyEventPartialState: "event_type", "entity_id", "state", - "context_id_bin", - "context_user_id_bin", - "context_parent_id_bin", "data", ] @@ -60,9 +57,6 @@ class LazyEventPartialState: self.event_type: str | None = self.row.event_type self.entity_id: str | None = self.row.entity_id self.state = self.row.state - self.context_id_bin: bytes | None = self.row.context_id_bin - self.context_user_id_bin: bytes | None = self.row.context_user_id_bin - self.context_parent_id_bin: bytes | None = self.row.context_parent_id_bin # We need to explicitly check for the row is EventAsRow as the unhappy path # to fetch row.data for Row is very expensive if type(row) is EventAsRow: # noqa: E721 @@ -83,17 +77,17 @@ class LazyEventPartialState: @property def context_id(self) -> str | None: """Return the context id.""" - return bytes_to_ulid_or_none(self.context_id_bin) + return bytes_to_ulid_or_none(self.row.context_id_bin) @property def context_user_id(self) -> str | None: """Return the context user id.""" - return bytes_to_uuid_hex_or_none(self.context_user_id_bin) + return bytes_to_uuid_hex_or_none(self.row.context_user_id_bin) @property def context_parent_id(self) -> str | None: """Return the context parent id.""" - return bytes_to_ulid_or_none(self.context_parent_id_bin) + return bytes_to_ulid_or_none(self.row.context_parent_id_bin) @dataclass(slots=True, frozen=True) diff --git a/tests/components/logbook/test_models.py b/tests/components/logbook/test_models.py new file mode 100644 index 00000000000..6f3c6bfefcb --- /dev/null +++ b/tests/components/logbook/test_models.py @@ -0,0 +1,20 @@ +"""The tests for the logbook component models.""" +from unittest.mock import Mock + +from homeassistant.components.logbook.models import LazyEventPartialState + + +def test_lazy_event_partial_state_context(): + """Test we can extract context from a lazy event partial state.""" + state = LazyEventPartialState( + Mock( + context_id_bin=b"1234123412341234", + context_user_id_bin=b"1234123412341234", + context_parent_id_bin=b"4444444444444444", + event_data={}, + ), + {}, + ) + assert state.context_id == "1H68SK8C9J6CT32CHK6GRK4CSM" + assert state.context_user_id == "31323334313233343132333431323334" + assert state.context_parent_id == "1M6GT38D1M6GT38D1M6GT38D1M" From b9b986dc8d9aed83141a8c3a917d217406793fb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 10:54:34 -0500 Subject: [PATCH 220/982] Bump protobuf to 4.25.0 (#103373) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 62a8b722dc9..5f8eda0856e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -150,7 +150,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.3 +protobuf==4.25.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2668affee96..cb202ed0466 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -150,7 +150,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.3 +protobuf==4.25.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From d078a4396cc1264b33c856897f1828027dc53c08 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 4 Nov 2023 17:59:12 +0100 Subject: [PATCH 221/982] Remove platform YAML from Qnap (#103377) --- homeassistant/components/qnap/config_flow.py | 12 --- homeassistant/components/qnap/sensor.py | 77 +------------------- tests/components/qnap/test_config_flow.py | 24 ------ 3 files changed, 2 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py index 689fe30a870..04b5340fa8a 100644 --- a/homeassistant/components/qnap/config_flow.py +++ b/homeassistant/components/qnap/config_flow.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, - CONF_MONITORED_CONDITIONS, CONF_PASSWORD, CONF_PORT, CONF_SSL, @@ -22,9 +21,6 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from .const import ( - CONF_DRIVES, - CONF_NICS, - CONF_VOLUMES, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_TIMEOUT, @@ -51,14 +47,6 @@ class QnapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Set the config entry up from yaml.""" - import_info.pop(CONF_MONITORED_CONDITIONS, None) - import_info.pop(CONF_NICS, None) - import_info.pop(CONF_DRIVES, None) - import_info.pop(CONF_VOLUMES, None) - return await self.async_step_user(import_info) - async def async_step_user( self, user_input: dict[str, Any] | None = None, diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index dfd03deca16..4677d2aabb6 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -1,13 +1,8 @@ """Support for QNAP NAS Sensors.""" from __future__ import annotations -import logging - -import voluptuous as vol - from homeassistant import config_entries from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -15,40 +10,20 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_NAME, - CONF_HOST, - CONF_MONITORED_CONDITIONS, - CONF_PASSWORD, - CONF_PORT, - CONF_SSL, - CONF_TIMEOUT, - CONF_USERNAME, - CONF_VERIFY_SSL, PERCENTAGE, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_DRIVES, - CONF_NICS, - CONF_VOLUMES, - DEFAULT_PORT, - DEFAULT_TIMEOUT, - DOMAIN, -) +from .const import DOMAIN from .coordinator import QnapCoordinator -_LOGGER = logging.getLogger(__name__) - ATTR_DRIVE = "Drive" ATTR_IP = "IP Address" ATTR_MAC = "MAC Address" @@ -221,54 +196,6 @@ SENSOR_KEYS: list[str] = [ ) ] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ), - vol.Optional(CONF_NICS): cv.ensure_list, - vol.Optional(CONF_DRIVES): cv.ensure_list, - vol.Optional(CONF_VOLUMES): cv.ensure_list, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the qnap sensor platform from yaml.""" - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "QNAP", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/qnap/test_config_flow.py b/tests/components/qnap/test_config_flow.py index eb77109d62e..75af07cbf8b 100644 --- a/tests/components/qnap/test_config_flow.py +++ b/tests/components/qnap/test_config_flow.py @@ -84,27 +84,3 @@ async def test_config_flow(hass: HomeAssistant, qnap_connect: MagicMock) -> None CONF_VERIFY_SSL: const.DEFAULT_VERIFY_SSL, CONF_PORT: const.DEFAULT_PORT, } - - -async def test_config_flow_import(hass: HomeAssistant) -> None: - """Test import of YAML config.""" - data = STANDARD_CONFIG - data[CONF_SSL] = const.DEFAULT_SSL - data[CONF_VERIFY_SSL] = const.DEFAULT_VERIFY_SSL - data[CONF_PORT] = const.DEFAULT_PORT - result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=data, - ) - - assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Test NAS name" - assert result["data"] == { - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "admin", - CONF_PASSWORD: "password", - CONF_SSL: const.DEFAULT_SSL, - CONF_VERIFY_SSL: const.DEFAULT_VERIFY_SSL, - CONF_PORT: const.DEFAULT_PORT, - } From 8e8f2a216369bd6cd77244350d311e9902ea2fb5 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 4 Nov 2023 17:08:06 +0000 Subject: [PATCH 222/982] Don't assume that the `sleep` value is a dictionary in Tractive integration (#103138) * Sleep value can be null * Catch TypeError --- homeassistant/components/tractive/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index f2853e0032c..300d7ebafc7 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -240,7 +240,7 @@ class TractiveClient: self._config_entry.data[CONF_EMAIL], ) return - except KeyError as error: + except (KeyError, TypeError) as error: _LOGGER.error("Error while listening for events: %s", error) continue except aiotractive.exceptions.TractiveError: @@ -284,11 +284,16 @@ class TractiveClient: ) def _send_wellness_update(self, event: dict[str, Any]) -> None: + sleep_day = None + sleep_night = None + if isinstance(event["sleep"], dict): + sleep_day = event["sleep"]["minutes_day_sleep"] + sleep_night = event["sleep"]["minutes_night_sleep"] payload = { ATTR_ACTIVITY_LABEL: event["wellness"].get("activity_label"), ATTR_CALORIES: event["activity"]["calories"], - ATTR_MINUTES_DAY_SLEEP: event["sleep"]["minutes_day_sleep"], - ATTR_MINUTES_NIGHT_SLEEP: event["sleep"]["minutes_night_sleep"], + ATTR_MINUTES_DAY_SLEEP: sleep_day, + ATTR_MINUTES_NIGHT_SLEEP: sleep_night, ATTR_MINUTES_REST: event["activity"]["minutes_rest"], ATTR_SLEEP_LABEL: event["wellness"].get("sleep_label"), } From d0603729f2f3927d7b37034d61705ba2901a186c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 4 Nov 2023 18:56:53 +0100 Subject: [PATCH 223/982] Remove platform YAML from DWD Weather Warnings (#103379) --- .../dwd_weather_warnings/config_flow.py | 27 +------ .../components/dwd_weather_warnings/sensor.py | 62 +-------------- .../dwd_weather_warnings/test_config_flow.py | 77 +------------------ 3 files changed, 5 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py index e806db7ec91..1e0bb797c7a 100644 --- a/homeassistant/components/dwd_weather_warnings/config_flow.py +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -8,11 +8,10 @@ from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from .const import CONF_REGION_IDENTIFIER, CONF_REGION_NAME, DOMAIN, LOGGER +from .const import CONF_REGION_IDENTIFIER, DOMAIN class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): @@ -51,27 +50,3 @@ class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): } ), ) - - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - LOGGER.debug( - "Starting import of sensor from configuration.yaml - %s", import_config - ) - - # Extract the necessary data for the setup. - region_identifier = import_config[CONF_REGION_NAME] - name = import_config.get(CONF_NAME, region_identifier) - - # Set the unique ID for this imported entry. - await self.async_set_unique_id(region_identifier) - self._abort_if_unique_id_configured() - - # Validate region identifier using the API - if not await self.hass.async_add_executor_job( - DwdWeatherWarningsAPI, region_identifier - ): - return self.async_abort(reason="invalid_identifier") - - return self.async_create_entry( - title=name, data={CONF_REGION_IDENTIFIER: region_identifier} - ) diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 6274bb4d529..e88fb3c408b 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -11,23 +11,11 @@ Wetterwarnungen (Stufe 1) from __future__ import annotations -from typing import Final - -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -46,7 +34,6 @@ from .const import ( ATTR_REGION_ID, ATTR_REGION_NAME, ATTR_WARNING_COUNT, - CONF_REGION_NAME, CURRENT_WARNING_SENSOR, DEFAULT_NAME, DOMAIN, @@ -66,49 +53,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), ) -# Should be removed together with the old YAML configuration. -YAML_MONITORED_CONDITIONS: Final = [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_REGION_NAME): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=YAML_MONITORED_CONDITIONS - ): vol.All(cv.ensure_list, [vol.In(YAML_MONITORED_CONDITIONS)]), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import the configurations from YAML to config flows.""" - # Show issue as long as the YAML configuration exists. - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Deutscher Wetterdienst (DWD) Weather Warnings", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/tests/components/dwd_weather_warnings/test_config_flow.py b/tests/components/dwd_weather_warnings/test_config_flow.py index 819d98e25ef..625532a4f04 100644 --- a/tests/components/dwd_weather_warnings/test_config_flow.py +++ b/tests/components/dwd_weather_warnings/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.components.dwd_weather_warnings.const import ( CURRENT_WARNING_SENSOR, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -70,81 +70,6 @@ async def test_create_entry(hass: HomeAssistant) -> None: } -async def test_import_flow_full_data(hass: HomeAssistant) -> None: - """Test import of a full YAML configuration with both success and failure.""" - # Test abort due to invalid identifier. - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=False, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=DEMO_YAML_CONFIGURATION.copy(), - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "invalid_identifier" - - # Test successful import. - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=DEMO_YAML_CONFIGURATION.copy(), - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Unit Test" - assert result["data"] == { - CONF_REGION_IDENTIFIER: "807111000", - } - - -async def test_import_flow_no_name(hass: HomeAssistant) -> None: - """Test a successful import of a YAML configuration with no name set.""" - data = DEMO_YAML_CONFIGURATION.copy() - data.pop(CONF_NAME) - - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=data - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "807111000" - assert result["data"] == { - CONF_REGION_IDENTIFIER: "807111000", - } - - -async def test_import_flow_already_configured(hass: HomeAssistant) -> None: - """Test aborting, if the warncell ID / name is already configured during the import.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=DEMO_CONFIG_ENTRY.copy(), - unique_id=DEMO_CONFIG_ENTRY[CONF_REGION_IDENTIFIER], - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=DEMO_YAML_CONFIGURATION.copy() - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_config_flow_already_configured(hass: HomeAssistant) -> None: """Test aborting, if the warncell ID / name is already configured during the config.""" entry = MockConfigEntry( From 941239262a2bde85347363a318cbea9410ad2786 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 4 Nov 2023 19:24:34 +0100 Subject: [PATCH 224/982] Remove platform YAML from LastFM (#103391) --- .../components/lastfm/config_flow.py | 19 ------- homeassistant/components/lastfm/sensor.py | 49 ++----------------- tests/components/lastfm/test_config_flow.py | 43 +--------------- tests/components/lastfm/test_sensor.py | 25 ---------- 4 files changed, 4 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 54406a6e03b..4ff809b56d0 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, ) -from homeassistant.helpers.typing import ConfigType from .const import CONF_MAIN_USER, CONF_USERS, DOMAIN @@ -154,24 +153,6 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_import(self, import_config: ConfigType) -> FlowResult: - """Import config from yaml.""" - for entry in self._async_current_entries(): - if entry.options[CONF_API_KEY] == import_config[CONF_API_KEY]: - return self.async_abort(reason="already_configured") - users, _ = validate_lastfm_users( - import_config[CONF_API_KEY], import_config[CONF_USERS] - ) - return self.async_create_entry( - title="LastFM", - data={}, - options={ - CONF_API_KEY: import_config[CONF_API_KEY], - CONF_MAIN_USER: None, - CONF_USERS: users, - }, - ) - class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): """LastFm Options flow handler.""" diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 40d6521bdc9..2b022a00107 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -4,17 +4,11 @@ from __future__ import annotations import hashlib from typing import Any -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -28,43 +22,6 @@ from .const import ( ) from .coordinator import LastFMDataUpdateCoordinator, LastFMUserData -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_USERS, default=[]): vol.All(cv.ensure_list, [cv.string]), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Last.fm sensor platform from yaml.""" - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LastFM", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/lastfm/test_config_flow.py b/tests/components/lastfm/test_config_flow.py index 07e96afaced..8a2c556a8d0 100644 --- a/tests/components/lastfm/test_config_flow.py +++ b/tests/components/lastfm/test_config_flow.py @@ -11,10 +11,9 @@ from homeassistant.components.lastfm.const import ( DEFAULT_NAME, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType from . import ( API_KEY, @@ -22,7 +21,6 @@ from . import ( CONF_FRIENDS_DATA, CONF_USER_DATA, USERNAME_1, - USERNAME_2, MockUser, patch_setup_entry, ) @@ -158,45 +156,6 @@ async def test_flow_friends_no_friends( assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 -async def test_import_flow_success(hass: HomeAssistant, default_user: MockUser) -> None: - """Test import flow.""" - with patch("pylast.User", return_value=default_user): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_API_KEY: API_KEY, CONF_USERS: [USERNAME_1, USERNAME_2]}, - ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "LastFM" - assert result["options"] == { - "api_key": "asdasdasdasdasd", - "main_user": None, - "users": ["testaccount1", "testaccount2"], - } - - -async def test_import_flow_already_exist( - hass: HomeAssistant, - setup_integration: ComponentSetup, - imported_config_entry: MockConfigEntry, - default_user: MockUser, -) -> None: - """Test import of yaml already exist.""" - await setup_integration(imported_config_entry, default_user) - - with patch("pylast.User", return_value=default_user): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=CONF_DATA, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_options_flow( hass: HomeAssistant, setup_integration: ComponentSetup, diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index f5723215e2a..f33419dd8ea 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -1,39 +1,14 @@ """Tests for the lastfm sensor.""" -from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lastfm.const import CONF_USERS, DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component -from . import API_KEY, USERNAME_1, MockUser from .conftest import ComponentSetup from tests.common import MockConfigEntry -LEGACY_CONFIG = { - Platform.SENSOR: [ - {CONF_PLATFORM: DOMAIN, CONF_API_KEY: API_KEY, CONF_USERS: [USERNAME_1]} - ] -} - - -async def test_legacy_migration(hass: HomeAssistant) -> None: - """Test migration from yaml to config flow.""" - with patch("pylast.User", return_value=MockUser()): - assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 - @pytest.mark.parametrize( ("fixture"), From c04db6a249346b2f2f2d0ce0e2221b9879283dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Sat, 4 Nov 2023 21:55:50 +0200 Subject: [PATCH 225/982] Bump vallox_websocket_api to 4.0.2 (#103339) --- homeassistant/components/vallox/__init__.py | 2 +- homeassistant/components/vallox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vallox/test_switch.py | 10 ++++++++-- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 1feda8e694a..ce40e07e294 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -135,7 +135,7 @@ class ValloxState: @property def sw_version(self) -> str: """Return the SW version.""" - return cast(str, _api_get_sw_version(self.metric_cache)) + return _api_get_sw_version(self.metric_cache) @property def uuid(self) -> UUID | None: diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 479c84d238c..c06bc036e4e 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vallox", "iot_class": "local_polling", "loggers": ["vallox_websocket_api"], - "requirements": ["vallox-websocket-api==3.3.0"] + "requirements": ["vallox-websocket-api==4.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 56f7aaf3511..15843840744 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2658,7 +2658,7 @@ url-normalize==1.4.3 uvcclient==0.11.0 # homeassistant.components.vallox -vallox-websocket-api==3.3.0 +vallox-websocket-api==4.0.2 # homeassistant.components.rdw vehicle==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7e0dc73ff4..d9ec8538fd4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1976,7 +1976,7 @@ url-normalize==1.4.3 uvcclient==0.11.0 # homeassistant.components.vallox -vallox-websocket-api==3.3.0 +vallox-websocket-api==4.0.2 # homeassistant.components.rdw vehicle==2.0.0 diff --git a/tests/components/vallox/test_switch.py b/tests/components/vallox/test_switch.py index 95232045af1..4739e6c4645 100644 --- a/tests/components/vallox/test_switch.py +++ b/tests/components/vallox/test_switch.py @@ -1,4 +1,6 @@ """Tests for Vallox switch platform.""" +from unittest.mock import patch + import pytest from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN @@ -30,7 +32,9 @@ async def test_switch_entities( metrics = {metric_key: value} # Act - with patch_metrics(metrics=metrics): + with patch_metrics(metrics=metrics), patch( + "homeassistant.components.vallox.Vallox.set_settable_address" + ): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() @@ -56,7 +60,9 @@ async def test_bypass_lock_switch_entitity_set( ) -> None: """Test bypass lock switch set.""" # Act - with patch_metrics(metrics={}), patch_metrics_set() as metrics_set: + with patch_metrics(metrics={}), patch_metrics_set() as metrics_set, patch( + "homeassistant.components.vallox.Vallox.set_settable_address" + ): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() await hass.services.async_call( From d3bbcbe27ce5181693dd249ffbc898facd2d2e98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 18:38:28 -0500 Subject: [PATCH 226/982] Pin jaraco.functools to fix builds and CI (#103406) --- homeassistant/components/abode/manifest.json | 2 +- requirements_all.txt | 3 +++ requirements_test_all.txt | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index d27def55251..c7d51c7ea1f 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_push", "loggers": ["jaraco.abode", "lomond"], - "requirements": ["jaraco.abode==3.3.0"] + "requirements": ["jaraco.abode==3.3.0", "jaraco.functools==3.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15843840744..45afdef003d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,6 +1097,9 @@ janus==1.0.0 # homeassistant.components.abode jaraco.abode==3.3.0 +# homeassistant.components.abode +jaraco.functools==3.9.0 + # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9ec8538fd4..2e7e3016e1d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -865,6 +865,9 @@ janus==1.0.0 # homeassistant.components.abode jaraco.abode==3.3.0 +# homeassistant.components.abode +jaraco.functools==3.9.0 + # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 From 936956a4305f09aa5c4bfab9b4ddbf7f88267e0d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 4 Nov 2023 17:36:00 -0700 Subject: [PATCH 227/982] Fix CalDAV supported components check when configured from the UI (#103411) * Fix CalDAV supported components check when configured from the UI * Move async_get_calendars to a seprate file * Simplify return value for async_get_calendars --- homeassistant/components/caldav/api.py | 25 ++++++++++++ homeassistant/components/caldav/calendar.py | 38 +++++++----------- tests/components/caldav/test_calendar.py | 43 +++++++++++++++++---- 3 files changed, 75 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/caldav/api.py diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py new file mode 100644 index 00000000000..b818e61dd2b --- /dev/null +++ b/homeassistant/components/caldav/api.py @@ -0,0 +1,25 @@ +"""Library for working with CalDAV api.""" + +import asyncio + +import caldav + +from homeassistant.core import HomeAssistant + + +async def async_get_calendars( + hass: HomeAssistant, client: caldav.DAVClient, component: str +) -> list[caldav.Calendar]: + """Get all calendars that support the specified component.""" + calendars = await hass.async_add_executor_job(client.principal().calendars) + components_results = await asyncio.gather( + *[ + hass.async_add_executor_job(calendar.get_supported_components) + for calendar in calendars + ] + ) + return [ + calendar + for calendar, supported_components in zip(calendars, components_results) + if component in supported_components + ] diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 73764d60419..b2114dfc829 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -24,11 +24,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .api import async_get_calendars from .const import DOMAIN from .coordinator import CalDavUpdateCoordinator @@ -43,7 +44,8 @@ CONF_DAYS = "days" # Number of days to look ahead for next event when configured by ConfigEntry CONFIG_ENTRY_DEFAULT_DAYS = 7 -OFFSET = "!!" +# Only allow VCALENDARs that support this component type +SUPPORTED_COMPONENT = "VEVENT" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -69,10 +71,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, disc_info: DiscoveryInfoType | None = None, ) -> None: """Set up the WebDav Calendar platform.""" @@ -85,9 +87,9 @@ def setup_platform( url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL] ) - calendars = client.principal().calendars() + calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) - calendar_devices = [] + entities = [] device_id: str | None for calendar in list(calendars): # If a calendar name was given in the configuration, @@ -104,7 +106,7 @@ def setup_platform( name = cust_calendar[CONF_NAME] device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}" - entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) coordinator = CalDavUpdateCoordinator( hass, calendar=calendar, @@ -112,26 +114,16 @@ def setup_platform( include_all_day=True, search=cust_calendar[CONF_SEARCH], ) - calendar_devices.append( + entities.append( WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True) ) # Create a default calendar if there was no custom one for all calendars # that support events. if not config[CONF_CUSTOM_CALENDARS]: - if ( - supported_components := calendar.get_supported_components() - ) and "VEVENT" not in supported_components: - _LOGGER.debug( - "Ignoring calendar '%s' (components=%s)", - calendar.name, - supported_components, - ) - continue - name = calendar.name device_id = calendar.name - entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) coordinator = CalDavUpdateCoordinator( hass, calendar=calendar, @@ -139,11 +131,11 @@ def setup_platform( include_all_day=False, search=None, ) - calendar_devices.append( + entities.append( WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True) ) - add_entities(calendar_devices, True) + async_add_entities(entities, True) async def async_setup_entry( @@ -153,12 +145,12 @@ async def async_setup_entry( ) -> None: """Set up the CalDav calendar platform for a config entry.""" client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id] - calendars = await hass.async_add_executor_job(client.principal().calendars) + calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) async_add_entities( ( WebDavCalendarEntity( calendar.name, - generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), + async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), CalDavUpdateCoordinator( hass, calendar=calendar, diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 023dae3facd..8a947747ab9 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -1056,7 +1056,6 @@ async def test_get_events_custom_calendars( _mock_calendar("Calendar 1", supported_components=["VEVENT"]), _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), _mock_calendar("Calendar 3", supported_components=["VTODO"]), - # Fallback to allow when no components are supported to be conservative _mock_calendar("Calendar 4", supported_components=[]), ] ], @@ -1069,22 +1068,17 @@ async def test_calendar_components(hass: HomeAssistant) -> None: state = hass.states.get("calendar.calendar_1") assert state - assert state.name == "Calendar 1" - assert state.state == STATE_OFF state = hass.states.get("calendar.calendar_2") assert state - assert state.name == "Calendar 2" - assert state.state == STATE_OFF # No entity created for To-do only component state = hass.states.get("calendar.calendar_3") assert not state + # No entity created when no components exist state = hass.states.get("calendar.calendar_4") - assert state - assert state.name == "Calendar 4" - assert state.state == STATE_OFF + assert not state @pytest.mark.parametrize("tz", [UTC]) @@ -1109,3 +1103,36 @@ async def test_setup_config_entry( "location": "Hamburg", "description": "What a beautiful day", } + + +@pytest.mark.parametrize( + ("calendars"), + [ + [ + _mock_calendar("Calendar 1", supported_components=["VEVENT"]), + _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), + _mock_calendar("Calendar 3", supported_components=["VTODO"]), + _mock_calendar("Calendar 4", supported_components=[]), + ] + ], +) +async def test_config_entry_supported_components( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test that calendars are only created for VEVENT types when using a config entry.""" + assert await setup_integration() + + state = hass.states.get("calendar.calendar_1") + assert state + + state = hass.states.get("calendar.calendar_2") + assert state + + # No entity created for To-do only component + state = hass.states.get("calendar.calendar_3") + assert not state + + # No entity created when no components exist + state = hass.states.get("calendar.calendar_4") + assert not state From 3ba8a8224365b65d8e6fdcf900925ad18f375e95 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 5 Nov 2023 03:08:04 +0100 Subject: [PATCH 228/982] Differentiate between warnings and errors in check_config helper (#102902) * Differentiate between warnings and errors in check_config helper * Update tests * Treat configuration errors in frontend and its dependencies as errors * Improve test coverage * Address review comments * Improve test coverage * Improve test coverage * Address review comments * Add comment --- homeassistant/components/config/core.py | 16 +- homeassistant/helpers/check_config.py | 75 +++++++-- homeassistant/scripts/check_config.py | 22 ++- tests/components/config/test_core.py | 39 ++++- tests/helpers/test_check_config.py | 199 ++++++++++++++++++------ tests/scripts/test_check_config.py | 14 +- 6 files changed, 290 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 9771e12f1d6..4c64028874d 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -7,9 +7,8 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.components.sensor import async_update_suggested_units -from homeassistant.config import async_check_ha_config_file from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import check_config, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location, unit_system @@ -31,11 +30,18 @@ class CheckConfigView(HomeAssistantView): @require_admin async def post(self, request): """Validate configuration and return results.""" - errors = await async_check_ha_config_file(request.app["hass"]) - state = "invalid" if errors else "valid" + res = await check_config.async_check_ha_config_file(request.app["hass"]) - return self.json({"result": state, "errors": errors}) + state = "invalid" if res.errors else "valid" + + return self.json( + { + "result": state, + "errors": res.error_str or None, + "warnings": res.warning_str or None, + } + ) @websocket_api.require_admin diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index a5e68cb877d..64e57b09d59 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -48,6 +48,7 @@ class HomeAssistantConfig(OrderedDict): """Initialize HA config.""" super().__init__() self.errors: list[CheckConfigError] = [] + self.warnings: list[CheckConfigError] = [] def add_error( self, @@ -55,15 +56,30 @@ class HomeAssistantConfig(OrderedDict): domain: str | None = None, config: ConfigType | None = None, ) -> Self: - """Add a single error.""" + """Add an error.""" self.errors.append(CheckConfigError(str(message), domain, config)) return self @property def error_str(self) -> str: - """Return errors as a string.""" + """Concatenate all errors to a string.""" return "\n".join([err.message for err in self.errors]) + def add_warning( + self, + message: str, + domain: str | None = None, + config: ConfigType | None = None, + ) -> Self: + """Add a warning.""" + self.warnings.append(CheckConfigError(str(message), domain, config)) + return self + + @property + def warning_str(self) -> str: + """Concatenate all warnings to a string.""" + return "\n".join([err.message for err in self.warnings]) + async def async_check_ha_config_file( # noqa: C901 hass: HomeAssistant, @@ -82,11 +98,36 @@ async def async_check_ha_config_file( # noqa: C901 message = f"Package {package} setup failed. Component {component} {message}" domain = f"homeassistant.packages.{package}.{component}" pack_config = core_config[CONF_PACKAGES].get(package, config) - result.add_error(message, domain, pack_config) + result.add_warning(message, domain, pack_config) def _comp_error(ex: Exception, domain: str, config: ConfigType) -> None: """Handle errors from components: async_log_exception.""" - result.add_error(_format_config_error(ex, domain, config)[0], domain, config) + if domain in frontend_dependencies: + result.add_error( + _format_config_error(ex, domain, config)[0], domain, config + ) + else: + result.add_warning( + _format_config_error(ex, domain, config)[0], domain, config + ) + + async def _get_integration( + hass: HomeAssistant, domain: str + ) -> loader.Integration | None: + """Get an integration.""" + integration: loader.Integration | None = None + try: + integration = await async_get_integration_with_requirements(hass, domain) + except loader.IntegrationNotFound as ex: + # We get this error if an integration is not found. In recovery mode and + # safe mode, this currently happens for all custom integrations. Don't + # show errors for a missing integration in recovery mode or safe mode to + # not confuse the user. + if not hass.config.recovery_mode and not hass.config.safe_mode: + result.add_warning(f"Integration error: {domain} - {ex}") + except RequirementsNotFound as ex: + result.add_warning(f"Integration error: {domain} - {ex}") + return integration # Load configuration.yaml config_path = hass.config.path(YAML_CONFIG_FILE) @@ -122,22 +163,22 @@ async def async_check_ha_config_file( # noqa: C901 # Filter out repeating config sections components = {key.partition(" ")[0] for key in config} + frontend_dependencies: set[str] = set() + if "frontend" in components or "default_config" in components: + frontend = await _get_integration(hass, "frontend") + if frontend: + await frontend.resolve_dependencies() + frontend_dependencies = frontend.all_dependencies | {"frontend"} + # Process and validate config for domain in components: - try: - integration = await async_get_integration_with_requirements(hass, domain) - except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode and not hass.config.safe_mode: - result.add_error(f"Integration error: {domain} - {ex}") - continue - except RequirementsNotFound as ex: - result.add_error(f"Integration error: {domain} - {ex}") + if not (integration := await _get_integration(hass, domain)): continue try: component = integration.get_component() except ImportError as ex: - result.add_error(f"Component error: {domain} - {ex}") + result.add_warning(f"Component error: {domain} - {ex}") continue # Check if the integration has a custom config validator @@ -216,14 +257,18 @@ async def async_check_ha_config_file( # noqa: C901 ) platform = p_integration.get_platform(domain) except loader.IntegrationNotFound as ex: + # We get this error if an integration is not found. In recovery mode and + # safe mode, this currently happens for all custom integrations. Don't + # show errors for a missing integration in recovery mode or safe mode to + # not confuse the user. if not hass.config.recovery_mode and not hass.config.safe_mode: - result.add_error(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning(f"Platform error {domain}.{p_name} - {ex}") continue except ( RequirementsNotFound, ImportError, ) as ex: - result.add_error(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning(f"Platform error {domain}.{p_name} - {ex}") continue # Validate platform specific schema diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 9a63c73590b..25922ab1f81 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -40,6 +40,7 @@ PATCHES: dict[str, Any] = {} C_HEAD = "bold" ERROR_STR = "General Errors" +WARNING_STR = "General Warnings" def color(the_color, *args, reset=None): @@ -116,6 +117,18 @@ def run(script_args: list) -> int: dump_dict(config, reset="red") print(color("reset")) + if res["warn"]: + print(color("bold_white", "Incorrect config")) + for domain, config in res["warn"].items(): + domain_info.append(domain) + print( + " ", + color("bold_yellow", domain + ":"), + color("yellow", "", reset="yellow"), + ) + dump_dict(config, reset="yellow") + print(color("reset")) + if domain_info: if "all" in domain_info: print(color("bold_white", "Successful config (all)")) @@ -160,7 +173,8 @@ def check(config_dir, secrets=False): res: dict[str, Any] = { "yaml_files": OrderedDict(), # yaml_files loaded "secrets": OrderedDict(), # secret cache and secrets loaded - "except": OrderedDict(), # exceptions raised (with config) + "except": OrderedDict(), # critical exceptions raised (with config) + "warn": OrderedDict(), # non critical exceptions raised (with config) #'components' is a HomeAssistantConfig # noqa: E265 "secret_cache": {}, } @@ -215,6 +229,12 @@ def check(config_dir, secrets=False): if err.config: res["except"].setdefault(domain, []).append(err.config) + for err in res["components"].warnings: + domain = err.domain or WARNING_STR + res["warn"].setdefault(domain, []).append(err.message) + if err.config: + res["warn"].setdefault(domain, []).append(err.config) + except Exception as err: # pylint: disable=broad-except print(color("red", "Fatal error while loading config:"), str(err)) res["except"].setdefault(ERROR_STR, []).append(str(err)) diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index fa7f33858a6..bd21e5e7d30 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -1,6 +1,6 @@ """Test core config.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -37,9 +37,14 @@ async def test_validate_config_ok( client = await hass_client() + no_error = Mock() + no_error.errors = None + no_error.error_str = "" + no_error.warning_str = "" + with patch( - "homeassistant.components.config.core.async_check_ha_config_file", - return_value=None, + "homeassistant.components.config.core.check_config.async_check_ha_config_file", + return_value=no_error, ): resp = await client.post("/api/config/core/check_config") @@ -47,10 +52,16 @@ async def test_validate_config_ok( result = await resp.json() assert result["result"] == "valid" assert result["errors"] is None + assert result["warnings"] is None + + error_warning = Mock() + error_warning.errors = ["beer"] + error_warning.error_str = "beer" + error_warning.warning_str = "milk" with patch( - "homeassistant.components.config.core.async_check_ha_config_file", - return_value="beer", + "homeassistant.components.config.core.check_config.async_check_ha_config_file", + return_value=error_warning, ): resp = await client.post("/api/config/core/check_config") @@ -58,6 +69,24 @@ async def test_validate_config_ok( result = await resp.json() assert result["result"] == "invalid" assert result["errors"] == "beer" + assert result["warnings"] == "milk" + + warning = Mock() + warning.errors = None + warning.error_str = "" + warning.warning_str = "milk" + + with patch( + "homeassistant.components.config.core.check_config.async_check_ha_config_file", + return_value=warning, + ): + resp = await client.post("/api/config/core/check_config") + + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result["result"] == "valid" + assert result["errors"] is None + assert result["warnings"] == "milk" async def test_validate_config_requires_admin( diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 973dec7381e..73d6433315e 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -2,10 +2,13 @@ import logging from unittest.mock import Mock, patch +import pytest + from homeassistant.config import YAML_CONFIG_FILE from homeassistant.core import HomeAssistant from homeassistant.helpers.check_config import ( CheckConfigError, + HomeAssistantConfig, async_check_ha_config_file, ) import homeassistant.helpers.config_validation as cv @@ -40,6 +43,28 @@ def log_ha_config(conf): _LOGGER.debug("error[%s] = %s", cnt, err) +def _assert_warnings_errors( + res: HomeAssistantConfig, + expected_warnings: list[CheckConfigError], + expected_errors: list[CheckConfigError], +) -> None: + assert len(res.warnings) == len(expected_warnings) + assert len(res.errors) == len(expected_errors) + + expected_warning_str = "" + expected_error_str = "" + + for idx, expected_warning in enumerate(expected_warnings): + assert res.warnings[idx] == expected_warning + expected_warning_str += expected_warning.message + assert res.warning_str == expected_warning_str + + for idx, expected_error in enumerate(expected_errors): + assert res.errors[idx] == expected_error + expected_error_str += expected_error.message + assert res.error_str == expected_error_str + + async def test_bad_core_config(hass: HomeAssistant) -> None: """Test a bad core config setup.""" files = {YAML_CONFIG_FILE: BAD_CORE_CONFIG} @@ -47,13 +72,12 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: res = await async_check_ha_config_file(hass) log_ha_config(res) - assert isinstance(res.errors[0].message, str) - assert res.errors[0].domain == "homeassistant" - assert res.errors[0].config == {"unit_system": "bad"} - - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + error = CheckConfigError( + "not a valid value for dictionary value @ data['unit_system']", + "homeassistant", + {"unit_system": "bad"}, + ) + _assert_warnings_errors(res, [], [error]) async def test_config_platform_valid(hass: HomeAssistant) -> None: @@ -65,7 +89,7 @@ async def test_config_platform_valid(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} assert res["light"] == [{"platform": "demo"}] - assert not res.errors + _assert_warnings_errors(res, [], []) async def test_component_platform_not_found(hass: HomeAssistant) -> None: @@ -77,13 +101,10 @@ async def test_component_platform_not_found(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} - assert res.errors[0] == CheckConfigError( + warning = CheckConfigError( "Integration error: beer - Integration 'beer' not found.", None, None ) - - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + _assert_warnings_errors(res, [warning], []) async def test_component_requirement_not_found(hass: HomeAssistant) -> None: @@ -98,7 +119,7 @@ async def test_component_requirement_not_found(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} - assert res.errors[0] == CheckConfigError( + warning = CheckConfigError( ( "Integration error: test_custom_component - Requirements for" " test_custom_component not found: ['any']." @@ -106,10 +127,7 @@ async def test_component_requirement_not_found(hass: HomeAssistant) -> None: None, None, ) - - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + _assert_warnings_errors(res, [warning], []) async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: @@ -122,7 +140,7 @@ async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} - assert not res.errors + _assert_warnings_errors(res, [], []) async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: @@ -135,7 +153,55 @@ async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} - assert not res.errors + _assert_warnings_errors(res, [], []) + + +async def test_component_import_error(hass: HomeAssistant) -> None: + """Test errors if component with a requirement not found not found.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:"} + with patch( + "homeassistant.loader.Integration.get_component", + side_effect=ImportError("blablabla"), + ), patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + warning = CheckConfigError( + "Component error: light - blablabla", + None, + None, + ) + _assert_warnings_errors(res, [warning], []) + + +@pytest.mark.parametrize( + ("component", "errors", "warnings", "message"), + [ + ("frontend", 1, 0, "[blah] is an invalid option for [frontend]"), + ("http", 1, 0, "[blah] is an invalid option for [http]"), + ("logger", 0, 1, "[blah] is an invalid option for [logger]"), + ], +) +async def test_component_schema_error( + hass: HomeAssistant, component: str, errors: int, warnings: int, message: str +) -> None: + """Test schema error in component.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + f"frontend:\n{component}:\n blah:"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert len(res.errors) == errors + assert len(res.warnings) == warnings + + for err in res.errors: + assert message in err.message + for warn in res.warnings: + assert message in warn.message async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: @@ -149,13 +215,10 @@ async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} assert res["light"] == [] - assert res.errors[0] == CheckConfigError( + warning = CheckConfigError( "Platform error light.beer - Integration 'beer' not found.", None, None ) - - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + _assert_warnings_errors(res, [warning], []) async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: @@ -170,7 +233,7 @@ async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} assert res["light"] == [] - assert not res.errors + _assert_warnings_errors(res, [], []) async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: @@ -185,7 +248,47 @@ async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} assert res["light"] == [] - assert not res.errors + _assert_warnings_errors(res, [], []) + + +async def test_component_config_platform_import_error(hass: HomeAssistant) -> None: + """Test errors if config platform fails to import.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} + with patch( + "homeassistant.loader.Integration.get_platform", + side_effect=ImportError("blablabla"), + ), patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + error = CheckConfigError( + "Error importing config platform light: blablabla", + None, + None, + ) + _assert_warnings_errors(res, [], [error]) + + +async def test_component_platform_import_error(hass: HomeAssistant) -> None: + """Test errors if component or platform not found.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: demo"} + with patch( + "homeassistant.loader.Integration.get_platform", + side_effect=[None, ImportError("blablabla")], + ), patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant", "light"} + warning = CheckConfigError( + "Platform error light.demo - blablabla", + None, + None, + ) + _assert_warnings_errors(res, [warning], []) async def test_package_invalid(hass: HomeAssistant) -> None: @@ -195,27 +298,32 @@ async def test_package_invalid(hass: HomeAssistant) -> None: res = await async_check_ha_config_file(hass) log_ha_config(res) - assert res.errors[0].domain == "homeassistant.packages.p1.group" - assert res.errors[0].config == {"group": ["a"]} - # Only 1 error expected - res.errors.pop(0) - assert not res.errors - assert res.keys() == {"homeassistant"} + warning = CheckConfigError( + ( + "Package p1 setup failed. Component group cannot be merged. Expected a " + "dict." + ), + "homeassistant.packages.p1.group", + {"group": ["a"]}, + ) + _assert_warnings_errors(res, [warning], []) -async def test_bootstrap_error(hass: HomeAssistant) -> None: - """Test a valid platform setup.""" + +async def test_missing_included_file(hass: HomeAssistant) -> None: + """Test missing included file.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + "automation: !include no.yaml"} with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) log_ha_config(res) - assert res.errors[0].domain is None + assert len(res.errors) == 1 + assert len(res.warnings) == 0 - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + assert res.errors[0].message.startswith("Error loading") + assert res.errors[0].domain is None + assert res.errors[0].config is None async def test_automation_config_platform(hass: HomeAssistant) -> None: @@ -251,6 +359,7 @@ action: res = await async_check_ha_config_file(hass) assert len(res.get("automation", [])) == 1 assert len(res.errors) == 0 + assert len(res.warnings) == 0 assert "input_datetime" in res @@ -270,11 +379,12 @@ bla: } with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) - assert len(res.errors) == 1 - err = res.errors[0] - assert err.domain == "bla" - assert err.message == "Unexpected error calling config validator: Broken" - assert err.config == {"value": 1} + error = CheckConfigError( + "Unexpected error calling config validator: Broken", + "bla", + {"value": 1}, + ) + _assert_warnings_errors(res, [], [error]) async def test_removed_yaml_support(hass: HomeAssistant) -> None: @@ -292,3 +402,4 @@ async def test_removed_yaml_support(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} + _assert_warnings_errors(res, [], []) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index e410dd672ce..06dff1e0869 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -49,6 +49,7 @@ def test_bad_core_config(mock_is_file, event_loop, mock_hass_config_yaml: None) res = check_config.check(get_test_config_dir()) assert res["except"].keys() == {"homeassistant"} assert res["except"]["homeassistant"][1] == {"unit_system": "bad"} + assert res["warn"] == {} @pytest.mark.parametrize("hass_config_yaml", [BASE_CONFIG + "light:\n platform: demo"]) @@ -62,6 +63,7 @@ def test_config_platform_valid( assert res["except"] == {} assert res["secret_cache"] == {} assert res["secrets"] == {} + assert res["warn"] == {} assert len(res["yaml_files"]) == 1 @@ -87,9 +89,10 @@ def test_component_platform_not_found( # Make sure they don't exist res = check_config.check(get_test_config_dir()) assert res["components"].keys() == platforms - assert res["except"] == {check_config.ERROR_STR: [error]} + assert res["except"] == {} assert res["secret_cache"] == {} assert res["secrets"] == {} + assert res["warn"] == {check_config.WARNING_STR: [error]} assert len(res["yaml_files"]) == 1 @@ -123,6 +126,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: get_test_config_dir("secrets.yaml"): {"http_pw": "http://google.com"} } assert res["secrets"] == {"http_pw": "http://google.com"} + assert res["warn"] == {} assert normalize_yaml_files(res) == [ ".../configuration.yaml", ".../secrets.yaml", @@ -136,13 +140,12 @@ def test_package_invalid(mock_is_file, event_loop, mock_hass_config_yaml: None) """Test an invalid package.""" res = check_config.check(get_test_config_dir()) - assert res["except"].keys() == {"homeassistant.packages.p1.group"} - assert res["except"]["homeassistant.packages.p1.group"][1] == {"group": ["a"]} - assert len(res["except"]) == 1 + assert res["except"] == {} assert res["components"].keys() == {"homeassistant"} - assert len(res["components"]) == 1 assert res["secret_cache"] == {} assert res["secrets"] == {} + assert res["warn"].keys() == {"homeassistant.packages.p1.group"} + assert res["warn"]["homeassistant.packages.p1.group"][1] == {"group": ["a"]} assert len(res["yaml_files"]) == 1 @@ -158,4 +161,5 @@ def test_bootstrap_error(event_loop, mock_hass_config_yaml: None) -> None: assert res["components"] == {} # No components, load failed assert res["secret_cache"] == {} assert res["secrets"] == {} + assert res["warn"] == {} assert res["yaml_files"] == {} From 108aed7843cab560b28e9140cd3b9a0b52177f84 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 5 Nov 2023 12:41:15 +0100 Subject: [PATCH 229/982] Fix serial in Flo device information (#103427) --- homeassistant/components/flo/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index 99e86d4b6b5..bcc52f512a1 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -139,9 +139,9 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): return self._device_information["fwVersion"] @property - def serial_number(self) -> str: + def serial_number(self) -> str | None: """Return the serial number for the device.""" - return self._device_information["serialNumber"] + return self._device_information.get("serialNumber") @property def pending_info_alerts_count(self) -> int: From 8ef3cf2e18897bf12e272cb3fc7e470fbaf27fc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Nov 2023 07:07:44 -0600 Subject: [PATCH 230/982] Bump bluetooth-data-tools to 0.14.0 (#103413) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.13.0...v1.14.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 06e7d34e68d..813bc900900 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.13.0", + "bluetooth-data-tools==1.14.0", "dbus-fast==2.12.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4619ffef4c5..4e8d3c8dde4 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "async-interrupt==1.1.1", "aioesphomeapi==18.2.1", - "bluetooth-data-tools==1.13.0", + "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index f82b2fff62b..7996376b6ac 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.13.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.14.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index a0f7685a2ec..21543ad6788 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.13.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.14.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 91ef843a864..663461ceaa1 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.13.0"] + "requirements": ["bluetooth-data-tools==1.14.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5f8eda0856e..b3e812022bf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.13.0 +bluetooth-data-tools==1.14.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.4 diff --git a/requirements_all.txt b/requirements_all.txt index 45afdef003d..699620b6994 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -565,7 +565,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.13.0 +bluetooth-data-tools==1.14.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e7e3016e1d..b4c2545c696 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -479,7 +479,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.13.0 +bluetooth-data-tools==1.14.0 # homeassistant.components.bond bond-async==0.2.1 From a8deb6afd0cdbdc743cf3b08d8e9c2d577897bec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Nov 2023 07:08:24 -0600 Subject: [PATCH 231/982] Bump zeroconf to 0.120.0 (#103412) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.119.0...0.120.0 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 8509d8133e2..dae92ef5aa3 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.119.0"] + "requirements": ["zeroconf==0.120.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b3e812022bf..2c38dc8f153 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -54,7 +54,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.119.0 +zeroconf==0.120.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 699620b6994..14ffffe0998 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2794,7 +2794,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.119.0 +zeroconf==0.120.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4c2545c696..4b0a2bb370e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2088,7 +2088,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.119.0 +zeroconf==0.120.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From fefea89d89355b442a71532c03ea7515aa9168f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 5 Nov 2023 14:09:29 +0100 Subject: [PATCH 232/982] Update aioairzone-cloud to v0.3.5 (#103315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update aioairzone-cloud to v0.3.3 Signed-off-by: Álvaro Fernández Rojas * Update aioairzone-cloud to v0.3.4 Reverts accidental TaskGroup changes. Signed-off-by: Álvaro Fernández Rojas * Update aioairzone-cloud to v0.3.5 Signed-off-by: Álvaro Fernández Rojas * Trigger Github CI --------- Signed-off-by: Álvaro Fernández Rojas Co-authored-by: J. Nick Koston --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index bbc8a84a3dc..ea22487f4a2 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.2"] + "requirements": ["aioairzone-cloud==0.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14ffffe0998..cb3430cf163 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.2 +aioairzone-cloud==0.3.5 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b0a2bb370e..70c59b49954 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.2 +aioairzone-cloud==0.3.5 # homeassistant.components.airzone aioairzone==0.6.9 From 751ebbda5190804953eb53bd4d276c8449e70a07 Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 5 Nov 2023 08:21:20 -0500 Subject: [PATCH 233/982] Use local variables in Blink (#103430) local variables --- homeassistant/components/blink/alarm_control_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index c19b07c5874..bf45ae7a582 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -57,7 +57,7 @@ class BlinkSyncModuleHA( ) -> None: """Initialize the alarm control panel.""" super().__init__(coordinator) - self.api: Blink = self.coordinator.api + self.api: Blink = coordinator.api self.sync = sync self._attr_unique_id: str = sync.serial self._attr_device_info = DeviceInfo( From 806205952ff863e2cf1875be406ea0254be5f13a Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Sun, 5 Nov 2023 09:42:07 -0800 Subject: [PATCH 234/982] Fix litterrobot test failure due to time zone dependence (#103444) * fix litterrobot test * use a date in northern hemisehpere summer --- tests/components/litterrobot/test_time.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/litterrobot/test_time.py b/tests/components/litterrobot/test_time.py index 6532f2a3bc7..53f254008e7 100644 --- a/tests/components/litterrobot/test_time.py +++ b/tests/components/litterrobot/test_time.py @@ -1,10 +1,11 @@ """Test the Litter-Robot time entity.""" from __future__ import annotations -from datetime import time +from datetime import datetime, time from unittest.mock import MagicMock from pylitterbot import LitterRobot3 +import pytest from homeassistant.components.time import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import ATTR_ENTITY_ID @@ -15,6 +16,7 @@ from .conftest import setup_integration SLEEP_START_TIME_ENTITY_ID = "time.test_sleep_mode_start_time" +@pytest.mark.freeze_time(datetime(2023, 7, 1, 12)) async def test_sleep_mode_start_time( hass: HomeAssistant, mock_account: MagicMock ) -> None: From 701a93c29893d9344b4b5d7d3f0d95c5e4b00b1b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 5 Nov 2023 20:20:13 +0100 Subject: [PATCH 235/982] Modbus set device_class in slaves (#103442) --- homeassistant/components/modbus/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index d7a6b4cca0f..52aa37535d6 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -168,6 +168,7 @@ class SlaveSensor( self._attr_unique_id = f"{self._attr_unique_id}_{idx}" self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = entry.get(CONF_STATE_CLASS) + self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = False super().__init__(coordinator) From 35791d7d6cfcc86b18d23d752b9b2d4bedfee12d Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sun, 5 Nov 2023 21:13:45 +0100 Subject: [PATCH 236/982] Bump pyatmo to v7.6.0 (#103410) Signed-off-by: Tobias Sauerwein --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/netatmo/snapshots/test_diagnostics.ambr | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 7fe1f9b8c04..d031632ed75 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==7.5.0"] + "requirements": ["pyatmo==7.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cb3430cf163..b4444749f4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1603,7 +1603,7 @@ pyairvisual==2023.08.1 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.5.0 +pyatmo==7.6.0 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70c59b49954..5360344cbf4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1221,7 +1221,7 @@ pyairvisual==2023.08.1 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.5.0 +pyatmo==7.6.0 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 228fc7563e0..bd9005bd389 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -572,6 +572,7 @@ 'read_smokedetector', 'read_station', 'read_thermostat', + 'read_mhs1', 'write_bubendorff', 'write_camera', 'write_magellan', @@ -579,6 +580,7 @@ 'write_presence', 'write_smarther', 'write_thermostat', + 'write_mhs1', ]), 'type': 'Bearer', }), From f88e137679043083dfbfa66c74ee5a83498f8279 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 5 Nov 2023 22:49:08 +0100 Subject: [PATCH 237/982] Fix fritz entity category binary sensor is invalid (#103470) --- homeassistant/components/fritzbox/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index dc56bc0473e..5d30362627e 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -49,7 +49,7 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( key="lock", translation_key="lock", device_class=BinarySensorDeviceClass.LOCK, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.lock is not None, is_on=lambda device: not device.lock, ), @@ -57,7 +57,7 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( key="device_lock", translation_key="device_lock", device_class=BinarySensorDeviceClass.LOCK, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.device_lock is not None, is_on=lambda device: not device.device_lock, ), From 43cab287009b4ac9783a991ebe45f48d3ebd6718 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Nov 2023 01:15:49 +0100 Subject: [PATCH 238/982] Remove platform YAML from GeoJSON (#103393) --- .../components/geo_json_events/config_flow.py | 27 +-------- .../geo_json_events/geo_location.py | 57 ++----------------- .../geo_json_events/test_config_flow.py | 51 +---------------- .../geo_json_events/test_geo_location.py | 24 +------- 4 files changed, 8 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/geo_json_events/config_flow.py b/homeassistant/components/geo_json_events/config_flow.py index cf58e8b57ce..ffa1c2070e9 100644 --- a/homeassistant/components/geo_json_events/config_flow.py +++ b/homeassistant/components/geo_json_events/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any import voluptuous as vol @@ -20,7 +19,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector from homeassistant.util.unit_conversion import DistanceConverter -from .const import DEFAULT_RADIUS_IN_KM, DEFAULT_RADIUS_IN_M, DOMAIN +from .const import DEFAULT_RADIUS_IN_M, DOMAIN DATA_SCHEMA = vol.Schema( { @@ -31,34 +30,10 @@ DATA_SCHEMA = vol.Schema( } ) -_LOGGER = logging.getLogger(__name__) - class GeoJsonEventsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a GeoJSON events config flow.""" - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - url: str = import_config[CONF_URL] - latitude: float = import_config.get(CONF_LATITUDE, self.hass.config.latitude) - longitude: float = import_config.get(CONF_LONGITUDE, self.hass.config.longitude) - self._async_abort_entries_match( - { - CONF_URL: url, - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, - } - ) - return self.async_create_entry( - title=f"{url} ({latitude}, {longitude})", - data={ - CONF_URL: url, - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, - CONF_RADIUS: import_config.get(CONF_RADIUS, DEFAULT_RADIUS_IN_KM), - }, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index c0192a0037d..8cb30535e66 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -6,28 +6,17 @@ import logging from typing import Any from aio_geojson_generic_client.feed_entry import GenericFeedEntry -import voluptuous as vol -from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_RADIUS, - CONF_URL, - UnitOfLength, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GeoJsonFeedEntityManager from .const import ( ATTR_EXTERNAL_ID, - DEFAULT_RADIUS_IN_KM, DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY, @@ -36,16 +25,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -# Deprecated. -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), - } -) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -72,34 +51,6 @@ async def async_setup_entry( _LOGGER.debug("Geolocation setup done") -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the GeoJSON Events platform.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "GeoJSON feed", - }, - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class GeoJsonLocationEvent(GeolocationEvent): """Represents an external event with GeoJSON data.""" diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py index 765f7c11482..a6e20ad4ba8 100644 --- a/tests/components/geo_json_events/test_config_flow.py +++ b/tests/components/geo_json_events/test_config_flow.py @@ -1,5 +1,4 @@ """Define tests for the GeoJSON Events config flow.""" -from datetime import timedelta import pytest @@ -10,14 +9,14 @@ from homeassistant.const import ( CONF_LOCATION, CONF_LONGITUDE, CONF_RADIUS, - CONF_SCAN_INTERVAL, CONF_URL, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import URL + from tests.common import MockConfigEntry -from tests.components.geo_json_events.conftest import URL pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -49,52 +48,6 @@ async def test_duplicate_error_user( assert result["reason"] == "already_configured" -async def test_duplicate_error_import( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test that errors are shown when duplicates are added.""" - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_URL: URL, - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - }, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_step_import(hass: HomeAssistant) -> None: - """Test that the import step works.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_URL: URL, - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - # This custom scan interval will not be carried over into the configuration. - CONF_SCAN_INTERVAL: timedelta(minutes=4), - }, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert ( - result["title"] == "http://geo.json.local/geo_json_events.json (-41.2, 174.7)" - ) - assert result["data"] == { - CONF_URL: URL, - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - } - - async def test_step_user(hass: HomeAssistant) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index ce650925200..a44357a5763 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -1,8 +1,7 @@ """The tests for the geojson platform.""" from datetime import timedelta -from unittest.mock import ANY, call, patch +from unittest.mock import patch -from aio_geojson_generic_client import GenericFeed from freezegun import freeze_time from homeassistant.components.geo_json_events.const import ( @@ -25,7 +24,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -44,26 +42,6 @@ CONFIG_LEGACY = { } -async def test_setup_as_legacy_platform(hass: HomeAssistant) -> None: - """Test the setup with YAML legacy configuration.""" - # Set up some mock feed entries for this test. - mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 20.5, (-31.1, 150.1)) - - with patch( - "aio_geojson_generic_client.feed_manager.GenericFeed", - wraps=GenericFeed, - ) as mock_feed, patch( - "aio_geojson_client.feed.GeoJsonFeed.update", - return_value=("OK", [mock_entry_1]), - ): - assert await async_setup_component(hass, GEO_LOCATION_DOMAIN, CONFIG_LEGACY) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 1 - - assert mock_feed.call_args == call(ANY, ANY, URL, filter_radius=190.0) - - async def test_entity_lifecycle( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 17acb04fb84c5d336476776f5c4676794e1e2588 Mon Sep 17 00:00:00 2001 From: rappenze Date: Mon, 6 Nov 2023 01:30:06 +0100 Subject: [PATCH 239/982] Refactor fibaro config flow test (#102604) * Refactor fibaro config flow test * Use constants from FlowResultType * Extend tests with recovery after failure * Add recovery from failure in all tests --- tests/components/fibaro/test_config_flow.py | 386 +++++++++----------- 1 file changed, 178 insertions(+), 208 deletions(-) diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index cb3d35d6f43..42d20f902c0 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Fibaro config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest from requests.exceptions import HTTPError @@ -10,52 +10,53 @@ from homeassistant.components.fibaro.config_flow import _normalize_url from homeassistant.components.fibaro.const import CONF_IMPORT_PLUGINS from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from .conftest import TEST_NAME, TEST_PASSWORD, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry -TEST_SERIALNUMBER = "HC2-111111" -TEST_NAME = "my_fibaro_home_center" -TEST_URL = "http://192.168.1.1/api/" -TEST_USERNAME = "user" -TEST_PASSWORD = "password" -TEST_VERSION = "4.360" - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_fibaro_client") -@pytest.fixture(name="fibaro_client", autouse=True) -def fibaro_client_fixture(): - """Mock common methods and attributes of fibaro client.""" - info_mock = Mock() - info_mock.return_value.serial_number = TEST_SERIALNUMBER - info_mock.return_value.hc_name = TEST_NAME - info_mock.return_value.current_version = TEST_VERSION +async def _recovery_after_failure_works( + hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult +) -> None: + mock_fibaro_client.connect.side_effect = None + mock_fibaro_client.connect.return_value = True - client_mock = Mock() - client_mock.base_url.return_value = TEST_URL + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) - with patch( - "homeassistant.components.fibaro.FibaroClient.__init__", - return_value=None, - ), patch( - "homeassistant.components.fibaro.FibaroClient.read_info", - info_mock, - create=True, - ), patch( - "homeassistant.components.fibaro.FibaroClient.read_rooms", - return_value=[], - ), patch( - "homeassistant.components.fibaro.FibaroClient.read_devices", - return_value=[], - ), patch( - "homeassistant.components.fibaro.FibaroClient.read_scenes", - return_value=[], - ), patch( - "homeassistant.components.fibaro.FibaroClient._rest_client", - client_mock, - create=True, - ): - yield + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: False, + } + + +async def _recovery_after_reauth_failure_works( + hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult +) -> None: + mock_fibaro_client.connect.side_effect = None + mock_fibaro_client.connect.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "other_fake_password"}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_config_flow_user_initiated_success(hass: HomeAssistant) -> None: @@ -64,270 +65,239 @@ async def test_config_flow_user_initiated_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - - assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME - assert result["data"] == { + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_URL: TEST_URL, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_IMPORT_PLUGINS: False, - } + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: False, + } -async def test_config_flow_user_initiated_connect_failure(hass: HomeAssistant) -> None: +async def test_config_flow_user_initiated_connect_failure( + hass: HomeAssistant, mock_fibaro_client: Mock +) -> None: """Connect failure in flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", - return_value=False, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_fibaro_client.connect.return_value = False - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + await _recovery_after_failure_works(hass, mock_fibaro_client, result) -async def test_config_flow_user_initiated_auth_failure(hass: HomeAssistant) -> None: +async def test_config_flow_user_initiated_auth_failure( + hass: HomeAssistant, mock_fibaro_client: Mock +) -> None: """Authentication failure in flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = HTTPError(response=Mock(status_code=403)) - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + await _recovery_after_failure_works(hass, mock_fibaro_client, result) async def test_config_flow_user_initiated_unknown_failure_1( - hass: HomeAssistant, + hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = HTTPError(response=Mock(status_code=500)) - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=500)) - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + await _recovery_after_failure_works(hass, mock_fibaro_client, result) async def test_config_flow_user_initiated_unknown_failure_2( - hass: HomeAssistant, + hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = Exception() - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_fibaro_client.connect.side_effect = Exception() - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_reauth_success(hass: HomeAssistant) -> None: - """Successful reauth flow initialized by the user.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - entry_id=TEST_SERIALNUMBER, - data={ + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_URL: TEST_URL, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_IMPORT_PLUGINS: False, }, ) - mock_config.add_to_hass(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + await _recovery_after_failure_works(hass, mock_fibaro_client, result) + + +async def test_reauth_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Successful reauth flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", return_value=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: "other_fake_password"}, - ) - - assert result["type"] == "abort" - assert result["reason"] == "reauth_successful" - - -async def test_reauth_connect_failure(hass: HomeAssistant) -> None: - """Successful reauth flow initialized by the user.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - entry_id=TEST_SERIALNUMBER, - data={ - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_IMPORT_PLUGINS: False, - }, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "other_fake_password"}, ) - mock_config.add_to_hass(hass) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_connect_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fibaro_client: Mock, +) -> None: + """Successful reauth flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = Exception() - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: "other_fake_password"}, - ) + mock_fibaro_client.connect.side_effect = Exception() - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_reauth_auth_failure(hass: HomeAssistant) -> None: - """Successful reauth flow initialized by the user.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - entry_id=TEST_SERIALNUMBER, - data={ - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_IMPORT_PLUGINS: False, - }, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "other_fake_password"}, ) - mock_config.add_to_hass(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + await _recovery_after_reauth_failure_works(hass, mock_fibaro_client, result) + + +async def test_reauth_auth_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fibaro_client: Mock, +) -> None: + """Successful reauth flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = HTTPError(response=Mock(status_code=403)) - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: "other_fake_password"}, - ) + mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "other_fake_password"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + await _recovery_after_reauth_failure_works(hass, mock_fibaro_client, result) @pytest.mark.parametrize("url_path", ["/api/", "/api", "/", ""]) From 77baea8cb799ccbdecf632e71a87c51d20342e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 6 Nov 2023 01:32:03 +0100 Subject: [PATCH 240/982] Allow setting HVAC mode through set_temperature service in Airzone integration (#103185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: climate: set_temperature: support ATTR_HVAC_MODE Signed-off-by: Álvaro Fernández Rojas * tests: airzone: set_temp: check HVAC mode Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/climate.py | 4 ++++ tests/components/airzone/test_climate.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index adbc6e1ff6e..22172255b9b 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -31,6 +31,7 @@ from aioairzone.const import ( ) from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, FAN_AUTO, @@ -222,6 +223,9 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW] await self._async_update_hvac_params(params) + if ATTR_HVAC_MODE in kwargs: + await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE]) + @callback def _handle_coordinator_update(self) -> None: """Update attributes when the coordinator updates.""" diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 94bea0a5e07..34844e34370 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -536,6 +536,7 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: API_SYSTEM_ID: 1, API_ZONE_ID: 5, API_SET_POINT: 20.5, + API_ON: 1, } ] } @@ -551,12 +552,14 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: "climate.dorm_2", + ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 20.5, }, blocking=True, ) state = hass.states.get("climate.dorm_2") + assert state.state == HVACMode.HEAT assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 From ab6b3d56682855e1eb7a92ba4baa6905cd1e01e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 6 Nov 2023 01:33:01 +0100 Subject: [PATCH 241/982] Allow setting HVAC mode through set_temperature service in Airzone Cloud integration (#103184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone_cloud: climate: set_temperature: support ATTR_HVAC_MODE Signed-off-by: Álvaro Fernández Rojas * tests: airzone_cloud: set_temp: check HVAC mode Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/climate.py | 7 +++++++ tests/components/airzone_cloud/test_climate.py | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index e5aa6be65e3..e076edc1f5b 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -32,6 +32,7 @@ from aioairzone_cloud.const import ( ) from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -204,6 +205,9 @@ class AirzoneDeviceClimate(AirzoneClimate): } await self._async_update_params(params) + if ATTR_HVAC_MODE in kwargs: + await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE]) + class AirzoneDeviceGroupClimate(AirzoneClimate): """Define an Airzone Cloud DeviceGroup base class.""" @@ -238,6 +242,9 @@ class AirzoneDeviceGroupClimate(AirzoneClimate): } await self._async_update_params(params) + if ATTR_HVAC_MODE in kwargs: + await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE]) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" params: dict[str, Any] = { diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index 4106b1af1e9..010c0d51072 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -453,12 +453,14 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: "climate.house", + ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 20.5, }, blocking=True, ) state = hass.states.get("climate.house") + assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 20.5 # Zones @@ -471,12 +473,14 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 20.5, }, blocking=True, ) state = hass.states.get("climate.salon") + assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 20.5 From f6cb7e1bc50a13184b2d034dcfbcca5da527bdb7 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 6 Nov 2023 05:07:57 +0200 Subject: [PATCH 242/982] Refactor tests for Islamic Prayer Times (#103439) Refactor tests --- .../islamic_prayer_times/__init__.py | 14 +++--- .../islamic_prayer_times/test_init.py | 49 ++++++++----------- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 386d20ab98e..b93c46108d8 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -35,13 +35,13 @@ NEW_PRAYER_TIMES = { } NEW_PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 1, 6, 00, 0, tzinfo=dt_util.UTC), - "Sunrise": datetime(2020, 1, 1, 7, 25, 0, tzinfo=dt_util.UTC), - "Dhuhr": datetime(2020, 1, 1, 12, 30, 0, tzinfo=dt_util.UTC), - "Asr": datetime(2020, 1, 1, 15, 32, 0, tzinfo=dt_util.UTC), - "Maghrib": datetime(2020, 1, 1, 17, 45, 0, tzinfo=dt_util.UTC), - "Isha": datetime(2020, 1, 1, 18, 53, 0, tzinfo=dt_util.UTC), - "Midnight": datetime(2020, 1, 1, 00, 43, 0, tzinfo=dt_util.UTC), + "Fajr": datetime(2020, 1, 2, 6, 00, 0, tzinfo=dt_util.UTC), + "Sunrise": datetime(2020, 1, 2, 7, 25, 0, tzinfo=dt_util.UTC), + "Dhuhr": datetime(2020, 1, 2, 12, 30, 0, tzinfo=dt_util.UTC), + "Asr": datetime(2020, 1, 2, 15, 32, 0, tzinfo=dt_util.UTC), + "Maghrib": datetime(2020, 1, 2, 17, 45, 0, tzinfo=dt_util.UTC), + "Isha": datetime(2020, 1, 2, 18, 53, 0, tzinfo=dt_util.UTC), + "Midnight": datetime(2020, 1, 2, 00, 43, 0, tzinfo=dt_util.UTC), } NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 6b3b112e042..a1fcf32efba 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -10,16 +10,11 @@ from homeassistant import config_entries from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - NEW_PRAYER_TIMES, - NEW_PRAYER_TIMES_TIMESTAMPS, - NOW, - PRAYER_TIMES, - PRAYER_TIMES_TIMESTAMPS, -) +from . import NEW_PRAYER_TIMES, NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS from tests.common import MockConfigEntry, async_fire_time_changed @@ -85,7 +80,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert islamic_prayer_times.DOMAIN not in hass.data async def test_options_listener(hass: HomeAssistant) -> None: @@ -108,8 +102,8 @@ async def test_options_listener(hass: HomeAssistant) -> None: assert mock_fetch_prayer_times.call_count == 2 -async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> None: - """Test Islamic prayer times timestamp format.""" +async def test_update_failed(hass: HomeAssistant) -> None: + """Test integrations tries to update after 1 min if update fails.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) entry.add_to_hass(hass) @@ -120,33 +114,30 @@ async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> Non await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.data[islamic_prayer_times.DOMAIN].data == PRAYER_TIMES_TIMESTAMPS - - -async def test_update(hass: HomeAssistant) -> None: - """Test sensors are updated with new prayer times.""" - entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) - entry.add_to_hass(hass) + assert entry.state is config_entries.ConfigEntryState.LOADED with patch( "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times" - ) as FetchPrayerTimes, freeze_time(NOW): + ) as FetchPrayerTimes: FetchPrayerTimes.side_effect = [ - PRAYER_TIMES, + InvalidResponseError, NEW_PRAYER_TIMES, ] + future = PRAYER_TIMES_TIMESTAMPS["Midnight"] + timedelta(days=1, minutes=1) + with freeze_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + state = hass.states.get("sensor.islamic_prayer_times_fajr_prayer") + assert state.state == STATE_UNAVAILABLE - pt_data = hass.data[islamic_prayer_times.DOMAIN] - assert pt_data.data == PRAYER_TIMES_TIMESTAMPS - - future = pt_data.data["Midnight"] + timedelta(days=1, minutes=1) - - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert pt_data.data == NEW_PRAYER_TIMES_TIMESTAMPS + # coordinator tries to update after 1 minute + future = future + timedelta(minutes=1) + with freeze_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get("sensor.islamic_prayer_times_fajr_prayer") + assert state.state == "2020-01-02T06:00:00+00:00" @pytest.mark.parametrize( From fd3d615c0d4f58a7801cf1ddbb21c629eecf42aa Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Sun, 5 Nov 2023 19:11:50 -0800 Subject: [PATCH 243/982] Handle smarttub sensor values being None (#103385) * [smarttub] handle sensor values being None * empty commit to rerun CI * lint * use const in test * reorder checks * use None instead of STATE_UNKNOWN * empty commit to rerun CI * check for STATE_UNKNOWN in test * empty commit to rerun CI --- .../components/smarttub/manifest.json | 2 +- homeassistant/components/smarttub/sensor.py | 6 ++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smarttub/test_sensor.py | 22 +++++++++++++++++++ 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 3b8b727015b..e8db096f31d 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["smarttub"], "quality_scale": "platinum", - "requirements": ["python-smarttub==0.0.33"] + "requirements": ["python-smarttub==0.0.35"] } diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index a72555962eb..c362e1ea8f0 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -89,10 +89,14 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return the current state of the sensor.""" + if self._state is None: + return None + if isinstance(self._state, Enum): return self._state.name.lower() + return self._state.lower() diff --git a/requirements_all.txt b/requirements_all.txt index b4444749f4d..205ef1d5079 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2190,7 +2190,7 @@ python-ripple-api==0.0.3 python-roborock==0.35.0 # homeassistant.components.smarttub -python-smarttub==0.0.33 +python-smarttub==0.0.35 # homeassistant.components.songpal python-songpal==0.15.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5360344cbf4..6fef6b5bafe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1634,7 +1634,7 @@ python-qbittorrent==0.4.3 python-roborock==0.35.0 # homeassistant.components.smarttub -python-smarttub==0.0.33 +python-smarttub==0.0.35 # homeassistant.components.songpal python-songpal==0.15.2 diff --git a/tests/components/smarttub/test_sensor.py b/tests/components/smarttub/test_sensor.py index 5c5359df381..5e476dcaaa5 100644 --- a/tests/components/smarttub/test_sensor.py +++ b/tests/components/smarttub/test_sensor.py @@ -2,6 +2,7 @@ import pytest import smarttub +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -27,6 +28,27 @@ async def test_sensor( assert state.state == expected_state +# https://github.com/home-assistant/core/issues/102339 +async def test_null_blowoutcycle( + spa, + spa_state, + config_entry, + hass: HomeAssistant, +) -> None: + """Test blowoutCycle having null value.""" + + spa_state.blowout_cycle = None + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = f"sensor.{spa.brand}_{spa.model}_blowout_cycle" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + async def test_primary_filtration( spa, spa_state, setup_entry, hass: HomeAssistant ) -> None: From 5eba6dbc9f80e3ff01f611c8dd3911fafe825c6d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 6 Nov 2023 07:52:15 +0100 Subject: [PATCH 244/982] modbus Allow swap: byte for datatype: string. (#103441) --- homeassistant/components/modbus/validators.py | 6 +++--- tests/components/modbus/test_sensor.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index bef58b3fa56..5fa314d589c 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -63,8 +63,8 @@ PARM_IS_LEGAL = namedtuple( ], ) # PARM_IS_LEGAL defines if the keywords: -# count: .. -# structure: .. +# count: +# structure: # swap: byte # swap: word # swap: word_byte (identical to swap: word) @@ -84,7 +84,7 @@ DEFAULT_STRUCT_FORMAT = { DataType.INT64: ENTRY("q", 4, PARM_IS_LEGAL(False, False, True, True, True)), DataType.UINT64: ENTRY("Q", 4, PARM_IS_LEGAL(False, False, True, True, True)), DataType.FLOAT64: ENTRY("d", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.STRING: ENTRY("s", -1, PARM_IS_LEGAL(True, False, False, False, False)), + DataType.STRING: ENTRY("s", -1, PARM_IS_LEGAL(True, False, False, True, False)), DataType.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)), } diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 0f79a125c86..72aebbd396f 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -513,6 +513,20 @@ async def test_config_wrong_struct_sensor( False, "07-05-2020 14:35", ), + ( + { + CONF_COUNT: 8, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DATA_TYPE: DataType.STRING, + CONF_SWAP: CONF_SWAP_BYTE, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + }, + [0x3730, 0x302D, 0x2D35, 0x3032, 0x3032, 0x3120, 0x3A34, 0x3533], + False, + "07-05-2020 14:35", + ), ( { CONF_COUNT: 8, From 52736c603914932340f28d48fcae37824b28ebc9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 6 Nov 2023 07:58:13 +0100 Subject: [PATCH 245/982] Sort Withings sleep data on end date (#103454) * Sort Withings sleep data on end date * Sort Withings sleep data on end date --- .../components/withings/coordinator.py | 5 +- .../withings/fixtures/sleep_summaries.json | 78 +++++++++---------- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 35eeb6e62b6..7dec48a3489 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -147,7 +147,10 @@ class WithingsSleepDataUpdateCoordinator( ) if not response: return None - return response[0] + + return sorted( + response, key=lambda sleep_summary: sleep_summary.end_date, reverse=True + )[0] class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): diff --git a/tests/components/withings/fixtures/sleep_summaries.json b/tests/components/withings/fixtures/sleep_summaries.json index 1bcfcfcc1d2..4e7f05142d3 100644 --- a/tests/components/withings/fixtures/sleep_summaries.json +++ b/tests/components/withings/fixtures/sleep_summaries.json @@ -1,43 +1,4 @@ [ - { - "id": 2081804182, - "timezone": "Europe/Paris", - "model": 32, - "model_id": 63, - "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", - "startdate": 1618691453, - "enddate": 1618713173, - "date": "2021-04-18", - "data": { - "wakeupduration": 3060, - "wakeupcount": 1, - "durationtosleep": 540, - "remsleepduration": 2400, - "durationtowakeup": 1140, - "total_sleep_time": 18660, - "sleep_efficiency": 0.86, - "sleep_latency": 540, - "wakeup_latency": 1140, - "waso": 1380, - "nb_rem_episodes": 1, - "out_of_bed_count": 0, - "lightsleepduration": 10440, - "deepsleepduration": 5820, - "hr_average": 103, - "hr_min": 70, - "hr_max": 120, - "rr_average": 14, - "rr_min": 10, - "rr_max": 20, - "breathing_disturbances_intensity": 9, - "snoring": 1080, - "snoringepisodecount": 18, - "sleep_score": 37, - "apnea_hypopnea_index": 9 - }, - "created": 1620237476, - "modified": 1620237476 - }, { "id": 2081804265, "timezone": "Europe/Paris", @@ -77,6 +38,45 @@ "created": 1620237480, "modified": 1620237479 }, + { + "id": 2081804182, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618691453, + "enddate": 1618713173, + "date": "2021-04-18", + "data": { + "wakeupduration": 3060, + "wakeupcount": 1, + "durationtosleep": 540, + "remsleepduration": 2400, + "durationtowakeup": 1140, + "total_sleep_time": 18660, + "sleep_efficiency": 0.86, + "sleep_latency": 540, + "wakeup_latency": 1140, + "waso": 1380, + "nb_rem_episodes": 1, + "out_of_bed_count": 0, + "lightsleepduration": 10440, + "deepsleepduration": 5820, + "hr_average": 103, + "hr_min": 70, + "hr_max": 120, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 9, + "snoring": 1080, + "snoringepisodecount": 18, + "sleep_score": 37, + "apnea_hypopnea_index": 9 + }, + "created": 1620237476, + "modified": 1620237476 + }, { "id": 2081804358, "timezone": "Europe/Paris", From 70196d5ee0cce97eebab4785e536cba1d5960457 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 6 Nov 2023 08:39:40 +0100 Subject: [PATCH 246/982] Fix KNX expose default value when attribute is `None` (#103446) Fix KNX expose default value when attribute is `null` --- homeassistant/components/knx/expose.py | 10 +++++----- tests/components/knx/test_expose.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index e14ee501d7b..d5c871d59ba 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -122,12 +122,12 @@ class KNXExposeSensor: """Extract value from state.""" if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): value = self.expose_default + elif self.expose_attribute is not None: + _attr = state.attributes.get(self.expose_attribute) + value = _attr if _attr is not None else self.expose_default else: - value = ( - state.state - if self.expose_attribute is None - else state.attributes.get(self.expose_attribute, self.expose_default) - ) + value = state.state + if self.expose_type == "binary": if value in (1, STATE_ON, "True"): return True diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index ca3fc5c7f58..4359c54164a 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -85,6 +85,14 @@ async def test_expose_attribute(hass: HomeAssistant, knx: KNXTestKit) -> None: hass.states.async_set(entity_id, "off", {}) await knx.assert_telegram_count(0) + # Change attribute; keep state + hass.states.async_set(entity_id, "on", {attribute: 1}) + await knx.assert_write("1/1/8", (1,)) + + # Change state to "off"; null attribute + hass.states.async_set(entity_id, "off", {attribute: None}) + await knx.assert_telegram_count(0) + async def test_expose_attribute_with_default( hass: HomeAssistant, knx: KNXTestKit @@ -132,6 +140,14 @@ async def test_expose_attribute_with_default( hass.states.async_set(entity_id, "off", {}) await knx.assert_write("1/1/8", (0,)) + # Change state and attribute + hass.states.async_set(entity_id, "on", {attribute: 1}) + await knx.assert_write("1/1/8", (1,)) + + # Change state to "off"; null attribute + hass.states.async_set(entity_id, "off", {attribute: None}) + await knx.assert_write("1/1/8", (0,)) + async def test_expose_string(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test an expose to send string values of up to 14 bytes only.""" From 1b17f6d837dbeee58a902a23cb0fa668269da9f2 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 6 Nov 2023 09:50:41 +0200 Subject: [PATCH 247/982] Store Islamic Prayer Times coordinator in 'entry_id' key (#103405) --- .../components/islamic_prayer_times/__init__.py | 10 +++++++--- .../components/islamic_prayer_times/sensor.py | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 86ee94f7269..2925ca527bc 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = IslamicPrayerDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, coordinator) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator config_entry.async_on_unload( config_entry.add_update_listener(async_options_updated) ) @@ -46,15 +46,19 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok := await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ): - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data.pop(DOMAIN) + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN].pop( + config_entry.entry_id + ) if coordinator.event_unsub: coordinator.event_unsub() + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] return unload_ok async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: """Triggered by config entry options updates.""" - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] if coordinator.event_unsub: coordinator.event_unsub() await coordinator.async_request_refresh() diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 45270863f01..70b2c9d9cc6 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -54,7 +54,9 @@ async def async_setup_entry( ) -> None: """Set up the Islamic prayer times sensor platform.""" - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] async_add_entities( IslamicPrayerTimeSensor(coordinator, description) From aa8b36c4e264f923de16a874be64e1c7514e38ed Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 5 Nov 2023 23:54:30 -0800 Subject: [PATCH 248/982] Bump ical to 6.0.0 (#103482) --- homeassistant/components/local_calendar/calendar.py | 6 +++--- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/local_todo/todo.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index c8807d40cc1..2a90e3e9e19 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -9,9 +9,9 @@ from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream from ical.event import Event +from ical.exceptions import CalendarParseError from ical.store import EventStore, EventStoreError from ical.types import Range, Recur -from pydantic import ValidationError import voluptuous as vol from homeassistant.components.calendar import ( @@ -178,8 +178,8 @@ def _parse_event(event: dict[str, Any]) -> Event: event[key] = dt_util.as_local(value).replace(tzinfo=None) try: - return Event.parse_obj(event) - except ValidationError as err: + return Event(**event) + except CalendarParseError as err: _LOGGER.debug("Error parsing event input fields: %s (%s)", event, str(err)) raise vol.Invalid("Error parsing event input fields") from err diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index ac95c6b0f0e..d21048c191c 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==5.1.0"] + "requirements": ["ical==6.0.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 049a1824495..cf2a49f6510 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==5.1.0"] + "requirements": ["ical==6.0.0"] } diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 7e23d01ee46..f9832ad8730 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -7,9 +7,9 @@ from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream +from ical.exceptions import CalendarParseError from ical.store import TodoStore from ical.todo import Todo, TodoStatus -from pydantic import ValidationError from homeassistant.components.todo import ( TodoItem, @@ -74,7 +74,7 @@ def _convert_item(item: TodoItem) -> Todo: """Convert a HomeAssistant TodoItem to an ical Todo.""" try: return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) - except ValidationError as err: + except CalendarParseError as err: _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) raise HomeAssistantError("Error parsing todo input fields") from err diff --git a/requirements_all.txt b/requirements_all.txt index 205ef1d5079..a5b22950536 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1050,7 +1050,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==5.1.0 +ical==6.0.0 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fef6b5bafe..21a9f6f1773 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -830,7 +830,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==5.1.0 +ical==6.0.0 # homeassistant.components.ping icmplib==3.0 From 3cfb2d557f0baa90c231c9f0b141de3ad7ed30d0 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 6 Nov 2023 10:10:28 +0100 Subject: [PATCH 249/982] Bump evohome-async to 0.4.4 (#103084) * initial commit * use correct attr * fix hass-logger-period * initial commit * reduce footprint * reduce footprint 2 * reduce footprint 3 * reduce footprint 4 * reduce footprint 6 * reduce footprint 7 * reduce footprint 8 * reduce footprint 9 * bump client to 0.4.1 * missing commit - changed method name * bump client to 0.4.3 * bump client to 0.4.4 --- homeassistant/components/evohome/__init__.py | 82 +++++++++++-------- homeassistant/components/evohome/climate.py | 14 +--- .../components/evohome/manifest.json | 2 +- .../components/evohome/water_heater.py | 19 +++-- requirements_all.txt | 2 +- 5 files changed, 65 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 4b79ef3df1b..b848bc0f02c 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -10,7 +10,6 @@ import logging import re from typing import Any -import aiohttp.client_exceptions import evohomeasync import evohomeasync2 import voluptuous as vol @@ -144,7 +143,7 @@ def _handle_exception(err) -> None: try: raise err - except evohomeasync2.AuthenticationError: + except evohomeasync2.AuthenticationFailed: _LOGGER.error( ( "Failed to authenticate with the vendor's server. Check your username" @@ -155,19 +154,18 @@ def _handle_exception(err) -> None: err, ) - except aiohttp.ClientConnectionError: - # this appears to be a common occurrence with the vendor's servers - _LOGGER.warning( - ( - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: %s" - ), - err, - ) + except evohomeasync2.RequestFailed: + if err.status is None: + _LOGGER.warning( + ( + "Unable to connect with the vendor's server. " + "Check your network and the vendor's service status page. " + "Message is: %s" + ), + err, + ) - except aiohttp.ClientResponseError: - if err.status == HTTPStatus.SERVICE_UNAVAILABLE: + elif err.status == HTTPStatus.SERVICE_UNAVAILABLE: _LOGGER.warning( "The vendor says their server is currently unavailable. " "Check the vendor's service status page" @@ -219,7 +217,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: await client_v2.login() - except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + except evohomeasync2.AuthenticationFailed as err: _handle_exception(err) return False finally: @@ -452,7 +450,7 @@ class EvoBroker: """Call a client API and update the broker state if required.""" try: result = await api_function - except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + except evohomeasync2.EvohomeError as err: _handle_exception(err) return @@ -475,15 +473,27 @@ class EvoBroker: try: temps = list(await self.client_v1.temperatures(force_refresh=True)) - except aiohttp.ClientError as err: + except evohomeasync.InvalidSchema as exc: + _LOGGER.warning( + ( + "Unable to obtain high-precision temperatures. " + "It appears the JSON schema is not as expected, " + "so the high-precision feature will be disabled until next restart." + "Message is: %s" + ), + exc, + ) + self.temps = self.client_v1 = None + + except evohomeasync.EvohomeError as exc: _LOGGER.warning( ( "Unable to obtain the latest high-precision temperatures. " "Check your network and the vendor's service status page. " - "Proceeding with low-precision temperatures. " + "Proceeding without high-precision temperatures for now. " "Message is: %s" ), - err, + exc, ) self.temps = None # these are now stale, will fall back to v2 temps @@ -513,10 +523,11 @@ class EvoBroker: else: self.temps = {str(i["id"]): i["temp"] for i in temps} - _LOGGER.debug("Temperatures = %s", self.temps) + finally: + if session_id != get_session_id(self.client_v1): + await self.save_auth_tokens() - if session_id != get_session_id(self.client_v1): - await self.save_auth_tokens() + _LOGGER.debug("Temperatures = %s", self.temps) async def _update_v2_api_state(self, *args, **kwargs) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" @@ -524,8 +535,8 @@ class EvoBroker: loc_idx = self.params[CONF_LOCATION_IDX] try: - status = await self.client.locations[loc_idx].status() - except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + status = await self.client.locations[loc_idx].refresh_status() + except evohomeasync2.EvohomeError as err: _handle_exception(err) else: async_dispatcher_send(self.hass, DOMAIN) @@ -542,11 +553,14 @@ class EvoBroker: operating mode of the Controller and the current temp of its children (e.g. Zones, DHW controller). """ - if self.client_v1: - await self._update_v1_api_temps() - await self._update_v2_api_state() + if self.client_v1: + try: + await self._update_v1_api_temps() + except evohomeasync.EvohomeError: + self.temps = None # these are now stale, will fall back to v2 temps + class EvoDevice(Entity): """Base for any evohome device. @@ -618,11 +632,13 @@ class EvoChild(EvoDevice): @property def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" - if ( - self._evo_broker.temps - and self._evo_broker.temps[self._evo_device.zoneId] != 128 - ): - return self._evo_broker.temps[self._evo_device.zoneId] + if self._evo_device.TYPE == "domesticHotWater": + dev_id = self._evo_device.dhwId + else: + dev_id = self._evo_device.zoneId + + if self._evo_broker.temps and self._evo_broker.temps[dev_id] is not None: + return self._evo_broker.temps[dev_id] if self._evo_device.temperatureStatus["isAvailable"]: return self._evo_device.temperatureStatus["temperature"] @@ -695,7 +711,7 @@ class EvoChild(EvoDevice): async def _update_schedule(self) -> None: """Get the latest schedule, if any.""" self._schedule = await self._evo_broker.call_client_api( - self._evo_device.schedule(), update_state=False + self._evo_device.get_schedule(), update_state=False ) _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 3bee1d6062e..fb608262a7d 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -167,9 +167,7 @@ class EvoZone(EvoChild, EvoClimateEntity): async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (setpoint override) for a zone.""" if service == SVC_RESET_ZONE_OVERRIDE: - await self._evo_broker.call_client_api( - self._evo_device.cancel_temp_override() - ) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return # otherwise it is SVC_SET_ZONE_OVERRIDE @@ -264,18 +262,14 @@ class EvoZone(EvoChild, EvoClimateEntity): self._evo_device.set_temperature(self.min_temp, until=None) ) else: # HVACMode.HEAT - await self._evo_broker.call_client_api( - self._evo_device.cancel_temp_override() - ) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to following the schedule.""" evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW) if evo_preset_mode == EVO_FOLLOW: - await self._evo_broker.call_client_api( - self._evo_device.cancel_temp_override() - ) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return temperature = self._evo_device.setpointStatus["targetHeatTemperature"] @@ -352,7 +346,7 @@ class EvoController(EvoClimateEntity): """Set a Controller to any of its native EVO_* operating modes.""" until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( - self._evo_tcs.set_status(mode, until=until) + self._evo_tcs.set_mode(mode, until=until) ) @property diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 641833ef06a..3cf07dfdfc4 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.3.15"] + "requirements": ["evohome-async==0.4.4"] } diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 87c0a8a1ecd..5d49e9b46ec 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -46,9 +46,10 @@ async def async_setup_platform( _LOGGER.debug( "Adding: DhwController (%s), id=%s", - broker.tcs.hotwater.zone_type, - broker.tcs.hotwater.zoneId, + broker.tcs.hotwater.TYPE, + broker.tcs.hotwater.dhwId, ) + new_entity = EvoDHW(broker, broker.tcs.hotwater) async_add_entities([new_entity], update_before_add=True) @@ -95,7 +96,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity): Except for Auto, the mode is only until the next SetPoint. """ if operation_mode == STATE_AUTO: - await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) else: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) @@ -103,28 +104,28 @@ class EvoDHW(EvoChild, WaterHeaterEntity): if operation_mode == STATE_ON: await self._evo_broker.call_client_api( - self._evo_device.set_dhw_on(until=until) + self._evo_device.set_on(until=until) ) else: # STATE_OFF await self._evo_broker.call_client_api( - self._evo_device.set_dhw_off(until=until) + self._evo_device.set_off(until=until) ) async def async_turn_away_mode_on(self) -> None: """Turn away mode on.""" - await self._evo_broker.call_client_api(self._evo_device.set_dhw_off()) + await self._evo_broker.call_client_api(self._evo_device.set_off()) async def async_turn_away_mode_off(self) -> None: """Turn away mode off.""" - await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) async def async_turn_on(self): """Turn on.""" - await self._evo_broker.call_client_api(self._evo_device.set_dhw_on()) + await self._evo_broker.call_client_api(self._evo_device.set_on()) async def async_turn_off(self): """Turn off.""" - await self._evo_broker.call_client_api(self._evo_device.set_dhw_off()) + await self._evo_broker.call_client_api(self._evo_device.set_off()) async def async_update(self) -> None: """Get the latest state data for a DHW controller.""" diff --git a/requirements_all.txt b/requirements_all.txt index a5b22950536..384b86fece4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -785,7 +785,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.3.15 +evohome-async==0.4.4 # homeassistant.components.faa_delays faadelays==2023.9.1 From b580ca6e6f05efb207917c1239f72faff6bac4b9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Nov 2023 10:17:48 +0100 Subject: [PATCH 250/982] Add Check date service for Workday (#97280) --- .../components/workday/binary_sensor.py | 27 ++++++++++++- .../components/workday/services.yaml | 9 +++++ homeassistant/components/workday/strings.json | 12 ++++++ .../components/workday/test_binary_sensor.py | 40 ++++++++++++++++++- 4 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/workday/services.yaml diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index ebd665f38e7..2692c27d58a 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,19 +2,25 @@ from __future__ import annotations from datetime import date, timedelta +from typing import Final from holidays import ( HolidayBase, __version__ as python_holidays_version, country_holidays, ) +import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util import dt as dt_util from .const import ( @@ -30,6 +36,9 @@ from .const import ( LOGGER, ) +SERVICE_CHECK_DATE: Final = "check_date" +CHECK_DATE: Final = "check_date" + def validate_dates(holiday_list: list[str]) -> list[str]: """Validate and adds to list of dates to add or remove.""" @@ -109,6 +118,15 @@ async def async_setup_entry( _holiday_string = holiday_date.strftime("%Y-%m-%d") LOGGER.debug("%s %s", _holiday_string, name) + platform = async_get_current_platform() + platform.async_register_entity_service( + SERVICE_CHECK_DATE, + {vol.Required(CHECK_DATE): cv.date}, + "check_date", + None, + SupportsResponse.ONLY, + ) + async_add_entities( [ IsWorkdaySensor( @@ -192,3 +210,8 @@ class IsWorkdaySensor(BinarySensorEntity): if self.is_exclude(day_of_week, adjusted_date): self._attr_is_on = False + + async def check_date(self, check_date: date) -> ServiceResponse: + """Check if date is workday or not.""" + holiday_date = check_date in self._obj_holidays + return {"workday": not holiday_date} diff --git a/homeassistant/components/workday/services.yaml b/homeassistant/components/workday/services.yaml new file mode 100644 index 00000000000..00935cd7215 --- /dev/null +++ b/homeassistant/components/workday/services.yaml @@ -0,0 +1,9 @@ +check_date: + target: + entity: + integration: workday + fields: + check_date: + example: "2022-12-25" + selector: + date: diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 733ea595ec7..a05ab1fc669 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -146,5 +146,17 @@ } } } + }, + "services": { + "check_date": { + "name": "Check date", + "description": "Check if date is workday.", + "fields": { + "check_date": { + "name": "Date", + "description": "Date to check if workday." + } + } + } } } diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index eeeb765e4a8..e955bd0de0d 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -1,10 +1,12 @@ """Tests the Home Assistant workday binary sensor.""" -from datetime import datetime +from datetime import date, datetime from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.workday.binary_sensor import SERVICE_CHECK_DATE +from homeassistant.components.workday.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC @@ -273,3 +275,39 @@ async def test_setup_date_range( state = hass.states.get("binary_sensor.workday_sensor") assert state.state == "on" + + +async def test_check_date_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test check date service with response data.""" + + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_WITH_PROVINCE) + + hass.states.get("binary_sensor.workday_sensor") + + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 25), # Christmas Day + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": False}} + + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 23), # Normal Friday + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": True}} From 5dd787aa104c083eb8b6845248eca837e01fca12 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 6 Nov 2023 10:34:06 +0100 Subject: [PATCH 251/982] Validate entity category for binary_sensor (#103464) --- .../components/binary_sensor/__init__.py | 11 ++- tests/components/binary_sensor/test_init.py | 70 ++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 79e20c6f571..a84cbc18756 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -10,8 +10,9 @@ from typing import Literal, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -190,6 +191,14 @@ class BinarySensorEntity(Entity): _attr_is_on: bool | None = None _attr_state: None = None + async def async_internal_added_to_hass(self) -> None: + """Call when the binary sensor entity is added to hass.""" + await super().async_internal_added_to_hass() + if self.entity_category == EntityCategory.CONFIG: + raise HomeAssistantError( + f"Entity {self.entity_id} cannot be added as the entity category is set to config" + ) + def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class. diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index a35a6c906df..437a2e1efa6 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components import binary_sensor from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,6 +18,7 @@ from tests.common import ( mock_integration, mock_platform, ) +from tests.testing_config.custom_components.test.binary_sensor import MockBinarySensor TEST_DOMAIN = "test" @@ -126,3 +127,70 @@ async def test_name(hass: HomeAssistant) -> None: state = hass.states.get(entity4.entity_id) assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} + + +async def test_entity_category_config_raises_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error is raised when entity category is set to config.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup( + config_entry, binary_sensor.DOMAIN + ) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + description1 = binary_sensor.BinarySensorEntityDescription( + "diagnostic", entity_category=EntityCategory.DIAGNOSTIC + ) + entity1 = MockBinarySensor() + entity1.entity_description = description1 + entity1.entity_id = "binary_sensor.test1" + + description2 = binary_sensor.BinarySensorEntityDescription( + "config", entity_category=EntityCategory.CONFIG + ) + entity2 = MockBinarySensor() + entity2.entity_description = description2 + entity2.entity_id = "binary_sensor.test2" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test stt platform via config entry.""" + async_add_entities([entity1, entity2]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{binary_sensor.DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + 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() + + state1 = hass.states.get("binary_sensor.test1") + assert state1 is not None + state2 = hass.states.get("binary_sensor.test2") + assert state2 is None + assert ( + "Entity binary_sensor.test2 cannot be added as the entity category is set to config" + in caplog.text + ) From 779b19ca46d4d763ec67cf3144e4a2978b36a4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 6 Nov 2023 10:51:14 +0100 Subject: [PATCH 252/982] On Airzone cloud unload logout (#103487) --- homeassistant/components/airzone_cloud/__init__.py | 4 +++- tests/components/airzone_cloud/test_init.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 38c764d4889..7e787ef4c69 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -46,7 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.airzone.logout() return unload_ok diff --git a/tests/components/airzone_cloud/test_init.py b/tests/components/airzone_cloud/test_init.py index 3a6497fdeba..f8a7a710e08 100644 --- a/tests/components/airzone_cloud/test_init.py +++ b/tests/components/airzone_cloud/test_init.py @@ -24,6 +24,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.logout", + return_value=None, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.list_installations", return_value=[], From 6d567c3e0a1b2a88056c9e80485b782b800e2988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 6 Nov 2023 11:05:44 +0100 Subject: [PATCH 253/982] Bump pycfdns from 2.0.1 to 3.0.0 (#103426) --- .../components/cloudflare/__init__.py | 80 ++++++++---- .../components/cloudflare/config_flow.py | 61 +++------ .../components/cloudflare/helpers.py | 10 ++ .../components/cloudflare/manifest.json | 2 +- .../components/cloudflare/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloudflare/__init__.py | 26 ++-- tests/components/cloudflare/conftest.py | 10 +- .../components/cloudflare/test_config_flow.py | 30 +---- tests/components/cloudflare/test_helpers.py | 13 ++ tests/components/cloudflare/test_init.py | 121 +++++++++++++++--- 12 files changed, 225 insertions(+), 135 deletions(-) create mode 100644 homeassistant/components/cloudflare/helpers.py create mode 100644 tests/components/cloudflare/test_helpers.py diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index 9608347c8e7..1901bfdc0e7 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -1,17 +1,12 @@ """Update the IP addresses of your Cloudflare DNS records.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from aiohttp import ClientSession -from pycfdns import CloudflareUpdater -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareException, - CloudflareZoneException, -) +import pycfdns from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, CONF_ZONE @@ -37,32 +32,43 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Cloudflare from a config entry.""" session = async_get_clientsession(hass) - cfupdate = CloudflareUpdater( - session, - entry.data[CONF_API_TOKEN], - entry.data[CONF_ZONE], - entry.data[CONF_RECORDS], + client = pycfdns.Client( + api_token=entry.data[CONF_API_TOKEN], + client_session=session, ) try: - zone_id = await cfupdate.get_zone_id() - except CloudflareAuthenticationException as error: + dns_zones = await client.list_zones() + dns_zone = next( + zone for zone in dns_zones if zone["name"] == entry.data[CONF_ZONE] + ) + except pycfdns.AuthenticationException as error: raise ConfigEntryAuthFailed from error - except (CloudflareConnectionException, CloudflareZoneException) as error: + except pycfdns.ComunicationException as error: raise ConfigEntryNotReady from error async def update_records(now): """Set up recurring update.""" try: - await _async_update_cloudflare(session, cfupdate, zone_id) - except CloudflareException as error: + await _async_update_cloudflare( + session, client, dns_zone, entry.data[CONF_RECORDS] + ) + except ( + pycfdns.AuthenticationException, + pycfdns.ComunicationException, + ) as error: _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) async def update_records_service(call: ServiceCall) -> None: """Set up service for manual trigger.""" try: - await _async_update_cloudflare(session, cfupdate, zone_id) - except CloudflareException as error: + await _async_update_cloudflare( + session, client, dns_zone, entry.data[CONF_RECORDS] + ) + except ( + pycfdns.AuthenticationException, + pycfdns.ComunicationException, + ) as error: _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL) @@ -87,12 +93,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_cloudflare( session: ClientSession, - cfupdate: CloudflareUpdater, - zone_id: str, + client: pycfdns.Client, + dns_zone: pycfdns.ZoneModel, + target_records: list[str], ) -> None: - _LOGGER.debug("Starting update for zone %s", cfupdate.zone) + _LOGGER.debug("Starting update for zone %s", dns_zone["name"]) - records = await cfupdate.get_record_info(zone_id) + records = await client.list_dns_records(zone_id=dns_zone["id"], type="A") _LOGGER.debug("Records: %s", records) location_info = await async_detect_location_info(session) @@ -100,5 +107,28 @@ async def _async_update_cloudflare( if not location_info or not is_ipv4_address(location_info.ip): raise HomeAssistantError("Could not get external IPv4 address") - await cfupdate.update_records(zone_id, records, location_info.ip) - _LOGGER.debug("Update for zone %s is complete", cfupdate.zone) + filtered_records = [ + record + for record in records + if record["name"] in target_records and record["content"] != location_info.ip + ] + + if len(filtered_records) == 0: + _LOGGER.debug("All target records are up to date") + return + + await asyncio.gather( + *[ + client.update_dns_record( + zone_id=dns_zone["id"], + record_id=record["id"], + record_content=location_info.ip, + record_name=record["name"], + record_type=record["type"], + record_proxied=record["proxied"], + ) + for record in filtered_records + ] + ) + + _LOGGER.debug("Update for zone %s is complete", dns_zone["name"]) diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 215411bc667..99f6109be4a 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -5,12 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -from pycfdns import CloudflareUpdater -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns import voluptuous as vol from homeassistant.components import persistent_notification @@ -23,6 +18,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_RECORDS, DOMAIN +from .helpers import get_zone_id _LOGGER = logging.getLogger(__name__) @@ -33,54 +29,45 @@ DATA_SCHEMA = vol.Schema( ) -def _zone_schema(zones: list[str] | None = None) -> vol.Schema: +def _zone_schema(zones: list[pycfdns.ZoneModel] | None = None) -> vol.Schema: """Zone selection schema.""" zones_list = [] if zones is not None: - zones_list = zones + zones_list = [zones["name"] for zones in zones] return vol.Schema({vol.Required(CONF_ZONE): vol.In(zones_list)}) -def _records_schema(records: list[str] | None = None) -> vol.Schema: +def _records_schema(records: list[pycfdns.RecordModel] | None = None) -> vol.Schema: """Zone records selection schema.""" records_dict = {} if records: - records_dict = {name: name for name in records} + records_dict = {name["name"]: name["name"] for name in records} return vol.Schema({vol.Required(CONF_RECORDS): cv.multi_select(records_dict)}) async def _validate_input( - hass: HomeAssistant, data: dict[str, Any] -) -> dict[str, list[str] | None]: + hass: HomeAssistant, + data: dict[str, Any], +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ zone = data.get(CONF_ZONE) - records: list[str] | None = None + records: list[pycfdns.RecordModel] = [] - cfupdate = CloudflareUpdater( - async_get_clientsession(hass), - data[CONF_API_TOKEN], - zone, - [], + client = pycfdns.Client( + api_token=data[CONF_API_TOKEN], + client_session=async_get_clientsession(hass), ) - try: - zones: list[str] | None = await cfupdate.get_zones() - if zone: - zone_id = await cfupdate.get_zone_id() - records = await cfupdate.get_zone_records(zone_id, "A") - except CloudflareConnectionException as error: - raise CannotConnect from error - except CloudflareAuthenticationException as error: - raise InvalidAuth from error - except CloudflareZoneException as error: - raise InvalidZone from error + zones = await client.list_zones() + if zone and (zone_id := get_zone_id(zone, zones)) is not None: + records = await client.list_dns_records(zone_id=zone_id, type="A") return {"zones": zones, "records": records} @@ -95,8 +82,8 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Cloudflare config flow.""" self.cloudflare_config: dict[str, Any] = {} - self.zones: list[str] | None = None - self.records: list[str] | None = None + self.zones: list[pycfdns.ZoneModel] | None = None + self.records: list[pycfdns.RecordModel] | None = None async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Cloudflare.""" @@ -195,18 +182,16 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_validate_or_error( self, config: dict[str, Any] - ) -> tuple[dict[str, list[str] | None], dict[str, str]]: + ) -> tuple[dict[str, list[Any]], dict[str, str]]: errors: dict[str, str] = {} info = {} try: info = await _validate_input(self.hass, config) - except CannotConnect: + except pycfdns.ComunicationException: errors["base"] = "cannot_connect" - except InvalidAuth: + except pycfdns.AuthenticationException: errors["base"] = "invalid_auth" - except InvalidZone: - errors["base"] = "invalid_zone" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -220,7 +205,3 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" - - -class InvalidZone(HomeAssistantError): - """Error to indicate we cannot validate zone exists in account.""" diff --git a/homeassistant/components/cloudflare/helpers.py b/homeassistant/components/cloudflare/helpers.py new file mode 100644 index 00000000000..0542bce0980 --- /dev/null +++ b/homeassistant/components/cloudflare/helpers.py @@ -0,0 +1,10 @@ +"""Helpers for the CloudFlare integration.""" +import pycfdns + + +def get_zone_id(target_zone_name: str, zones: list[pycfdns.ZoneModel]) -> str | None: + """Get the zone ID for the target zone name.""" + for zone in zones: + if zone["name"] == target_zone_name: + return zone["id"] + return None diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json index 8c901de3984..0f689aa3e03 100644 --- a/homeassistant/components/cloudflare/manifest.json +++ b/homeassistant/components/cloudflare/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/cloudflare", "iot_class": "cloud_push", "loggers": ["pycfdns"], - "requirements": ["pycfdns==2.0.1"] + "requirements": ["pycfdns==3.0.0"] } diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json index 080be414b5c..75dc8f079c7 100644 --- a/homeassistant/components/cloudflare/strings.json +++ b/homeassistant/components/cloudflare/strings.json @@ -30,8 +30,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_zone": "Invalid zone" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", diff --git a/requirements_all.txt b/requirements_all.txt index 384b86fece4..a9795a14fa1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1630,7 +1630,7 @@ pybravia==0.3.3 pycarwings2==2.14 # homeassistant.components.cloudflare -pycfdns==2.0.1 +pycfdns==3.0.0 # homeassistant.components.channels pychannels==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21a9f6f1773..9165494b7fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1242,7 +1242,7 @@ pybotvac==0.0.24 pybravia==0.3.3 # homeassistant.components.cloudflare -pycfdns==2.0.1 +pycfdns==3.0.0 # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 diff --git a/tests/components/cloudflare/__init__.py b/tests/components/cloudflare/__init__.py index f2eaccab470..8ba8b23b65f 100644 --- a/tests/components/cloudflare/__init__.py +++ b/tests/components/cloudflare/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from pycfdns import CFRecord +import pycfdns from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_ZONE @@ -26,9 +26,8 @@ USER_INPUT_ZONE = {CONF_ZONE: "mock.com"} USER_INPUT_RECORDS = {CONF_RECORDS: ["ha.mock.com", "homeassistant.mock.com"]} -MOCK_ZONE = "mock.com" -MOCK_ZONE_ID = "mock-zone-id" -MOCK_ZONE_RECORDS = [ +MOCK_ZONE: pycfdns.ZoneModel = {"name": "mock.com", "id": "mock-zone-id"} +MOCK_ZONE_RECORDS: list[pycfdns.RecordModel] = [ { "id": "zone-record-id", "type": "A", @@ -77,21 +76,12 @@ async def init_integration( return entry -def _get_mock_cfupdate( - zone: str = MOCK_ZONE, - zone_id: str = MOCK_ZONE_ID, - records: list = MOCK_ZONE_RECORDS, -): - client = AsyncMock() +def _get_mock_client(zone: str = MOCK_ZONE, records: list = MOCK_ZONE_RECORDS): + client: pycfdns.Client = AsyncMock() - zone_records = [record["name"] for record in records] - cf_records = [CFRecord(record) for record in records] - - client.get_zones = AsyncMock(return_value=[zone]) - client.get_zone_records = AsyncMock(return_value=zone_records) - client.get_record_info = AsyncMock(return_value=cf_records) - client.get_zone_id = AsyncMock(return_value=zone_id) - client.update_records = AsyncMock(return_value=None) + client.list_zones = AsyncMock(return_value=[zone]) + client.list_dns_records = AsyncMock(return_value=records) + client.update_dns_record = AsyncMock(return_value=None) return client diff --git a/tests/components/cloudflare/conftest.py b/tests/components/cloudflare/conftest.py index 0d9ac040c8e..de0e1a85b77 100644 --- a/tests/components/cloudflare/conftest.py +++ b/tests/components/cloudflare/conftest.py @@ -3,15 +3,15 @@ from unittest.mock import patch import pytest -from . import _get_mock_cfupdate +from . import _get_mock_client @pytest.fixture def cfupdate(hass): """Mock the CloudflareUpdater for easier testing.""" - mock_cfupdate = _get_mock_cfupdate() + mock_cfupdate = _get_mock_client() with patch( - "homeassistant.components.cloudflare.CloudflareUpdater", + "homeassistant.components.cloudflare.pycfdns.Client", return_value=mock_cfupdate, ) as mock_api: yield mock_api @@ -20,9 +20,9 @@ def cfupdate(hass): @pytest.fixture def cfupdate_flow(hass): """Mock the CloudflareUpdater for easier config flow testing.""" - mock_cfupdate = _get_mock_cfupdate() + mock_cfupdate = _get_mock_client() with patch( - "homeassistant.components.cloudflare.config_flow.CloudflareUpdater", + "homeassistant.components.cloudflare.pycfdns.Client", return_value=mock_cfupdate, ) as mock_api: yield mock_api diff --git a/tests/components/cloudflare/test_config_flow.py b/tests/components/cloudflare/test_config_flow.py index c0373866580..21ee364eca3 100644 --- a/tests/components/cloudflare/test_config_flow.py +++ b/tests/components/cloudflare/test_config_flow.py @@ -1,9 +1,5 @@ """Test the Cloudflare config flow.""" -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -81,7 +77,7 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, cfupdate_flow) -> N DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = CloudflareConnectionException() + instance.list_zones.side_effect = pycfdns.ComunicationException() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -99,7 +95,7 @@ async def test_user_form_invalid_auth(hass: HomeAssistant, cfupdate_flow) -> Non DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = CloudflareAuthenticationException() + instance.list_zones.side_effect = pycfdns.AuthenticationException() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -109,24 +105,6 @@ async def test_user_form_invalid_auth(hass: HomeAssistant, cfupdate_flow) -> Non assert result["errors"] == {"base": "invalid_auth"} -async def test_user_form_invalid_zone(hass: HomeAssistant, cfupdate_flow) -> None: - """Test we handle invalid zone error.""" - instance = cfupdate_flow.return_value - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER} - ) - - instance.get_zones.side_effect = CloudflareZoneException() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_zone"} - - async def test_user_form_unexpected_exception( hass: HomeAssistant, cfupdate_flow ) -> None: @@ -137,7 +115,7 @@ async def test_user_form_unexpected_exception( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = Exception() + instance.list_zones.side_effect = Exception() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, diff --git a/tests/components/cloudflare/test_helpers.py b/tests/components/cloudflare/test_helpers.py new file mode 100644 index 00000000000..74bf8420f8a --- /dev/null +++ b/tests/components/cloudflare/test_helpers.py @@ -0,0 +1,13 @@ +"""Test Cloudflare integration helpers.""" +from homeassistant.components.cloudflare.helpers import get_zone_id + + +def test_get_zone_id(): + """Test get_zone_id.""" + zones = [ + {"id": "1", "name": "example.com"}, + {"id": "2", "name": "example.org"}, + ] + assert get_zone_id("example.com", zones) == "1" + assert get_zone_id("example.org", zones) == "2" + assert get_zone_id("example.net", zones) is None diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index 9d46a428042..d1c9cb3c352 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -1,22 +1,25 @@ """Test the Cloudflare integration.""" +from datetime import timedelta from unittest.mock import patch -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns import pytest -from homeassistant.components.cloudflare.const import DOMAIN, SERVICE_UPDATE_RECORDS +from homeassistant.components.cloudflare.const import ( + CONF_RECORDS, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + SERVICE_UPDATE_RECORDS, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +import homeassistant.util.dt as dt_util from homeassistant.util.location import LocationInfo from . import ENTRY_CONFIG, init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_unload_entry(hass: HomeAssistant, cfupdate) -> None: @@ -35,10 +38,7 @@ async def test_unload_entry(hass: HomeAssistant, cfupdate) -> None: @pytest.mark.parametrize( "side_effect", - ( - CloudflareConnectionException(), - CloudflareZoneException(), - ), + (pycfdns.ComunicationException(),), ) async def test_async_setup_raises_entry_not_ready( hass: HomeAssistant, cfupdate, side_effect @@ -49,7 +49,7 @@ async def test_async_setup_raises_entry_not_ready( entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) entry.add_to_hass(hass) - instance.get_zone_id.side_effect = side_effect + instance.list_zones.side_effect = side_effect await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -64,7 +64,7 @@ async def test_async_setup_raises_entry_auth_failed( entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) entry.add_to_hass(hass) - instance.get_zone_id.side_effect = CloudflareAuthenticationException() + instance.list_zones.side_effect = pycfdns.AuthenticationException() await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -81,7 +81,7 @@ async def test_async_setup_raises_entry_auth_failed( assert flow["context"]["entry_id"] == entry.entry_id -async def test_integration_services(hass: HomeAssistant, cfupdate) -> None: +async def test_integration_services(hass: HomeAssistant, cfupdate, caplog) -> None: """Test integration services.""" instance = cfupdate.return_value @@ -112,7 +112,8 @@ async def test_integration_services(hass: HomeAssistant, cfupdate) -> None: ) await hass.async_block_till_done() - instance.update_records.assert_called_once() + assert len(instance.update_dns_record.mock_calls) == 2 + assert "All target records are up to date" not in caplog.text async def test_integration_services_with_issue(hass: HomeAssistant, cfupdate) -> None: @@ -134,4 +135,92 @@ async def test_integration_services_with_issue(hass: HomeAssistant, cfupdate) -> ) await hass.async_block_till_done() - instance.update_records.assert_not_called() + instance.update_dns_record.assert_not_called() + + +async def test_integration_services_with_nonexisting_record( + hass: HomeAssistant, cfupdate, caplog +) -> None: + """Test integration services.""" + instance = cfupdate.return_value + + entry = await init_integration( + hass, data={**ENTRY_CONFIG, CONF_RECORDS: ["nonexisting.example.com"]} + ) + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.cloudflare.async_detect_location_info", + return_value=LocationInfo( + "0.0.0.0", + "US", + "USD", + "CA", + "California", + "San Diego", + "92122", + "America/Los_Angeles", + 32.8594, + -117.2073, + True, + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_RECORDS, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + instance.update_dns_record.assert_not_called() + assert "All target records are up to date" in caplog.text + + +async def test_integration_update_interval( + hass: HomeAssistant, + cfupdate, + caplog, +) -> None: + """Test integration update interval.""" + instance = cfupdate.return_value + + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.cloudflare.async_detect_location_info", + return_value=LocationInfo( + "0.0.0.0", + "US", + "USD", + "CA", + "California", + "San Diego", + "92122", + "America/Los_Angeles", + 32.8594, + -117.2073, + True, + ), + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2 + assert "All target records are up to date" not in caplog.text + + instance.list_dns_records.side_effect = pycfdns.AuthenticationException() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2 + + instance.list_dns_records.side_effect = pycfdns.ComunicationException() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2 From cee837962806fca69f23fd67fc08b874f98405f1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Nov 2023 11:12:33 +0100 Subject: [PATCH 254/982] Update tailscale to 0.6.0 (#103409) --- .../components/tailscale/diagnostics.py | 2 +- .../components/tailscale/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tailscale/conftest.py | 4 +- .../tailscale/snapshots/test_diagnostics.ambr | 87 +++++++++++++++++ .../components/tailscale/test_diagnostics.py | 95 ++----------------- 7 files changed, 99 insertions(+), 95 deletions(-) create mode 100644 tests/components/tailscale/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/tailscale/diagnostics.py b/homeassistant/components/tailscale/diagnostics.py index 0fd69a12825..687cee7741f 100644 --- a/homeassistant/components/tailscale/diagnostics.py +++ b/homeassistant/components/tailscale/diagnostics.py @@ -32,5 +32,5 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: TailscaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] # Round-trip via JSON to trigger serialization - devices = [json.loads(device.json()) for device in coordinator.data.values()] + devices = [json.loads(device.to_json()) for device in coordinator.data.values()] return async_redact_data({"devices": devices}, TO_REDACT) diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 088389060f5..14f4206f44f 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["tailscale==0.2.0"] + "requirements": ["tailscale==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a9795a14fa1..afadfe86607 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2539,7 +2539,7 @@ synology-srm==0.2.0 systembridgeconnector==3.8.4 # homeassistant.components.tailscale -tailscale==0.2.0 +tailscale==0.6.0 # homeassistant.components.tank_utility tank-utility==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9165494b7fd..3c4e0bdb52f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1893,7 +1893,7 @@ switchbot-api==1.2.1 systembridgeconnector==3.8.4 # homeassistant.components.tailscale -tailscale==0.2.0 +tailscale==0.6.0 # homeassistant.components.tellduslive tellduslive==0.10.11 diff --git a/tests/components/tailscale/conftest.py b/tests/components/tailscale/conftest.py index 12f11a5656d..ec3b6afa139 100644 --- a/tests/components/tailscale/conftest.py +++ b/tests/components/tailscale/conftest.py @@ -41,7 +41,7 @@ def mock_tailscale_config_flow() -> Generator[None, MagicMock, None]: "homeassistant.components.tailscale.config_flow.Tailscale", autospec=True ) as tailscale_mock: tailscale = tailscale_mock.return_value - tailscale.devices.return_value = Devices.parse_raw( + tailscale.devices.return_value = Devices.from_json( load_fixture("tailscale/devices.json") ).devices yield tailscale @@ -54,7 +54,7 @@ def mock_tailscale(request: pytest.FixtureRequest) -> Generator[None, MagicMock, if hasattr(request, "param") and request.param: fixture = request.param - devices = Devices.parse_raw(load_fixture(fixture)).devices + devices = Devices.from_json(load_fixture(fixture)).devices with patch( "homeassistant.components.tailscale.coordinator.Tailscale", autospec=True ) as tailscale_mock: diff --git a/tests/components/tailscale/snapshots/test_diagnostics.ambr b/tests/components/tailscale/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..eba8d9bd145 --- /dev/null +++ b/tests/components/tailscale/snapshots/test_diagnostics.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'devices': list([ + dict({ + 'addresses': '**REDACTED**', + 'advertised_routes': list([ + ]), + 'authorized': True, + 'blocks_incoming_connections': False, + 'client_connectivity': dict({ + 'client_supports': dict({ + 'hair_pinning': False, + 'ipv6': False, + 'pcp': False, + 'pmp': False, + 'udp': True, + 'upnp': False, + }), + 'endpoints': '**REDACTED**', + 'mapping_varies_by_dest_ip': False, + }), + 'client_version': '1.12.3-td91ea7286-ge1bbbd90c', + 'created': '2021-08-19T09:25:22+00:00', + 'device_id': '**REDACTED**', + 'enabled_routes': list([ + ]), + 'expires': '2022-02-15T09:25:22+00:00', + 'hostname': '**REDACTED**', + 'is_external': False, + 'key_expiry_disabled': False, + 'last_seen': '2021-09-16T06:11:23+00:00', + 'machine_key': '**REDACTED**', + 'name': '**REDACTED**', + 'node_key': '**REDACTED**', + 'os': 'iOS', + 'tags': list([ + ]), + 'update_available': True, + 'user': '**REDACTED**', + }), + dict({ + 'addresses': '**REDACTED**', + 'advertised_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'authorized': True, + 'blocks_incoming_connections': False, + 'client_connectivity': dict({ + 'client_supports': dict({ + 'hair_pinning': True, + 'ipv6': False, + 'pcp': False, + 'pmp': False, + 'udp': True, + 'upnp': False, + }), + 'endpoints': '**REDACTED**', + 'mapping_varies_by_dest_ip': False, + }), + 'client_version': '1.14.0-t5cff36945-g809e87bba', + 'created': '2021-08-29T09:49:06+00:00', + 'device_id': '**REDACTED**', + 'enabled_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'expires': '2022-02-25T09:49:06+00:00', + 'hostname': '**REDACTED**', + 'is_external': False, + 'key_expiry_disabled': False, + 'last_seen': '2021-11-15T20:37:03+00:00', + 'machine_key': '**REDACTED**', + 'name': '**REDACTED**', + 'node_key': '**REDACTED**', + 'os': 'linux', + 'tags': list([ + ]), + 'update_available': True, + 'user': '**REDACTED**', + }), + ]), + }) +# --- diff --git a/tests/components/tailscale/test_diagnostics.py b/tests/components/tailscale/test_diagnostics.py index a6b892dbc86..4f900db7401 100644 --- a/tests/components/tailscale/test_diagnostics.py +++ b/tests/components/tailscale/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Tailscale integration.""" +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,93 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "devices": [ - { - "addresses": REDACTED, - "device_id": REDACTED, - "user": REDACTED, - "name": REDACTED, - "hostname": REDACTED, - "client_version": "1.12.3-td91ea7286-ge1bbbd90c", - "update_available": True, - "os": "iOS", - "created": "2021-08-19T09:25:22+00:00", - "last_seen": "2021-09-16T06:11:23+00:00", - "key_expiry_disabled": False, - "expires": "2022-02-15T09:25:22+00:00", - "authorized": True, - "is_external": False, - "machine_key": REDACTED, - "node_key": REDACTED, - "blocks_incoming_connections": False, - "enabled_routes": [], - "advertised_routes": [], - "client_connectivity": { - "endpoints": REDACTED, - "derp": "", - "mapping_varies_by_dest_ip": False, - "latency": {}, - "client_supports": { - "hair_pinning": False, - "ipv6": False, - "pcp": False, - "pmp": False, - "udp": True, - "upnp": False, - }, - }, - }, - { - "addresses": REDACTED, - "device_id": REDACTED, - "user": REDACTED, - "name": REDACTED, - "hostname": REDACTED, - "client_version": "1.14.0-t5cff36945-g809e87bba", - "update_available": True, - "os": "linux", - "created": "2021-08-29T09:49:06+00:00", - "last_seen": "2021-11-15T20:37:03+00:00", - "key_expiry_disabled": False, - "expires": "2022-02-25T09:49:06+00:00", - "authorized": True, - "is_external": False, - "machine_key": REDACTED, - "node_key": REDACTED, - "blocks_incoming_connections": False, - "enabled_routes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], - "advertised_routes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], - "client_connectivity": { - "endpoints": REDACTED, - "derp": "", - "mapping_varies_by_dest_ip": False, - "latency": { - "Bangalore": {"latencyMs": 143.42505599999998}, - "Chicago": {"latencyMs": 101.123646}, - "Dallas": {"latencyMs": 136.85886}, - "Frankfurt": {"latencyMs": 18.968314}, - "London": {"preferred": True, "latencyMs": 14.314574}, - "New York City": {"latencyMs": 83.078912}, - "San Francisco": {"latencyMs": 148.215522}, - "Seattle": {"latencyMs": 181.553595}, - "Singapore": {"latencyMs": 164.566539}, - "São Paulo": {"latencyMs": 207.250179}, - "Tokyo": {"latencyMs": 226.90714300000002}, - }, - "client_supports": { - "hair_pinning": True, - "ipv6": False, - "pcp": False, - "pmp": False, - "udp": True, - "upnp": False, - }, - }, - }, - ] - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From a35f5dc6f5526f3d5f66fe031adec6406699fa16 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 6 Nov 2023 11:15:00 +0100 Subject: [PATCH 255/982] Split out coordinator and add tests for nibe_heatpump (#103452) * Separate coordinator in nibe heatpump * Add tests for coordinator in nibe * Correct errors in coordinator found during testing * If coil is missing we should still write state * async_shutdown did not call base class * Add more tests for coordinator * Add minimal test to climate --- .../components/nibe_heatpump/__init__.py | 236 +----------------- .../components/nibe_heatpump/binary_sensor.py | 3 +- .../components/nibe_heatpump/button.py | 3 +- .../components/nibe_heatpump/climate.py | 2 +- .../components/nibe_heatpump/coordinator.py | 234 +++++++++++++++++ .../components/nibe_heatpump/number.py | 3 +- .../components/nibe_heatpump/select.py | 3 +- .../components/nibe_heatpump/sensor.py | 3 +- .../components/nibe_heatpump/switch.py | 3 +- .../components/nibe_heatpump/water_heater.py | 9 +- tests/components/nibe_heatpump/__init__.py | 45 +++- tests/components/nibe_heatpump/conftest.py | 91 ++++--- .../nibe_heatpump/snapshots/test_climate.ambr | 53 ++++ .../snapshots/test_coordinator.ambr | 133 ++++++++++ tests/components/nibe_heatpump/test_button.py | 9 +- .../components/nibe_heatpump/test_climate.py | 58 +++++ .../nibe_heatpump/test_config_flow.py | 25 +- .../nibe_heatpump/test_coordinator.py | 130 ++++++++++ 18 files changed, 736 insertions(+), 307 deletions(-) create mode 100644 homeassistant/components/nibe_heatpump/coordinator.py create mode 100644 tests/components/nibe_heatpump/snapshots/test_climate.ambr create mode 100644 tests/components/nibe_heatpump/snapshots/test_coordinator.ambr create mode 100644 tests/components/nibe_heatpump/test_climate.py create mode 100644 tests/components/nibe_heatpump/test_coordinator.py diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index 01a51f015d9..058f3ef8711 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -1,20 +1,11 @@ """The Nibe Heat Pump integration.""" from __future__ import annotations -import asyncio -from collections import defaultdict -from collections.abc import Callable, Iterable -from datetime import timedelta -from typing import Any, Generic, TypeVar - -from nibe.coil import Coil, CoilData from nibe.connection import Connection from nibe.connection.modbus import Modbus from nibe.connection.nibegw import NibeGW, ProductInfo -from nibe.exceptions import CoilNotFoundException, ReadException -from nibe.heatpump import HeatPump, Model, Series +from nibe.heatpump import HeatPump, Model -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_IP_ADDRESS, @@ -22,16 +13,9 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) from .const import ( CONF_CONNECTION_TYPE, @@ -44,8 +28,8 @@ from .const import ( CONF_REMOTE_WRITE_PORT, CONF_WORD_SWAP, DOMAIN, - LOGGER, ) +from .coordinator import Coordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -131,218 +115,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: Coordinator = hass.data[DOMAIN].pop(entry.entry_id) - await coordinator.async_shutdown() + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -_DataTypeT = TypeVar("_DataTypeT") -_ContextTypeT = TypeVar("_ContextTypeT") - - -class ContextCoordinator( - Generic[_DataTypeT, _ContextTypeT], DataUpdateCoordinator[_DataTypeT] -): - """Update coordinator with context adjustments.""" - - @cached_property - def context_callbacks(self) -> dict[_ContextTypeT, list[CALLBACK_TYPE]]: - """Return a dict of all callbacks registered for a given context.""" - callbacks: dict[_ContextTypeT, list[CALLBACK_TYPE]] = defaultdict(list) - for update_callback, context in list(self._listeners.values()): - assert isinstance(context, set) - for address in context: - callbacks[address].append(update_callback) - return callbacks - - @callback - def async_update_context_listeners(self, contexts: Iterable[_ContextTypeT]) -> None: - """Update all listeners given a set of contexts.""" - update_callbacks: set[CALLBACK_TYPE] = set() - for context in contexts: - update_callbacks.update(self.context_callbacks.get(context, [])) - - for update_callback in update_callbacks: - update_callback() - - @callback - def async_add_listener( - self, update_callback: CALLBACK_TYPE, context: Any = None - ) -> Callable[[], None]: - """Wrap standard function to prune cached callback database.""" - assert isinstance(context, set) - context -= {None} - release = super().async_add_listener(update_callback, context) - self.__dict__.pop("context_callbacks", None) - - @callback - def release_update(): - release() - self.__dict__.pop("context_callbacks", None) - - return release_update - - -class Coordinator(ContextCoordinator[dict[int, CoilData], int]): - """Update coordinator for nibe heat pumps.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - heatpump: HeatPump, - connection: Connection, - ) -> None: - """Initialize coordinator.""" - super().__init__( - hass, LOGGER, name="Nibe Heat Pump", update_interval=timedelta(seconds=60) - ) - - self.data = {} - self.seed: dict[int, CoilData] = {} - self.connection = connection - self.heatpump = heatpump - self.task: asyncio.Task | None = None - - heatpump.subscribe(heatpump.COIL_UPDATE_EVENT, self._on_coil_update) - - def _on_coil_update(self, data: CoilData): - """Handle callback on coil updates.""" - coil = data.coil - self.data[coil.address] = data - self.seed[coil.address] = data - self.async_update_context_listeners([coil.address]) - - @property - def series(self) -> Series: - """Return which series of pump we are connected to.""" - return self.heatpump.series - - @property - def coils(self) -> list[Coil]: - """Return the full coil database.""" - return self.heatpump.get_coils() - - @property - def unique_id(self) -> str: - """Return unique id for this coordinator.""" - return self.config_entry.unique_id or self.config_entry.entry_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information for the main device.""" - return DeviceInfo(identifiers={(DOMAIN, self.unique_id)}) - - def get_coil_value(self, coil: Coil) -> int | str | float | None: - """Return a coil with data and check for validity.""" - if coil_with_data := self.data.get(coil.address): - return coil_with_data.value - return None - - def get_coil_float(self, coil: Coil) -> float | None: - """Return a coil with float and check for validity.""" - if value := self.get_coil_value(coil): - return float(value) - return None - - async def async_write_coil(self, coil: Coil, value: int | float | str) -> None: - """Write coil and update state.""" - data = CoilData(coil, value) - await self.connection.write_coil(data) - - self.data[coil.address] = data - - self.async_update_context_listeners([coil.address]) - - async def async_read_coil(self, coil: Coil) -> CoilData: - """Read coil and update state using callbacks.""" - return await self.connection.read_coil(coil) - - async def _async_update_data(self) -> dict[int, CoilData]: - self.task = asyncio.current_task() - try: - return await self._async_update_data_internal() - finally: - self.task = None - - async def _async_update_data_internal(self) -> dict[int, CoilData]: - result: dict[int, CoilData] = {} - - def _get_coils() -> Iterable[Coil]: - for address in sorted(self.context_callbacks.keys()): - if seed := self.seed.pop(address, None): - self.logger.debug("Skipping seeded coil: %d", address) - result[address] = seed - continue - - try: - coil = self.heatpump.get_coil_by_address(address) - except CoilNotFoundException as exception: - self.logger.debug("Skipping missing coil: %s", exception) - continue - yield coil - - try: - async for data in self.connection.read_coils(_get_coils()): - result[data.coil.address] = data - self.seed.pop(data.coil.address, None) - except ReadException as exception: - if not result: - raise UpdateFailed(f"Failed to update: {exception}") from exception - self.logger.debug( - "Some coils failed to update, and may be unsupported: %s", exception - ) - - return result - - async def async_shutdown(self): - """Make sure a coordinator is shut down as well as it's connection.""" - if self.task: - self.task.cancel() - await asyncio.wait((self.task,)) - self._unschedule_refresh() - await self.connection.stop() - - -class CoilEntity(CoordinatorEntity[Coordinator]): - """Base for coil based entities.""" - - _attr_has_entity_name = True - _attr_entity_registry_enabled_default = False - - def __init__( - self, coordinator: Coordinator, coil: Coil, entity_format: str - ) -> None: - """Initialize base entity.""" - super().__init__(coordinator, {coil.address}) - self.entity_id = async_generate_entity_id( - entity_format, coil.name, hass=coordinator.hass - ) - self._attr_name = coil.title - self._attr_unique_id = f"{coordinator.unique_id}-{coil.address}" - self._attr_device_info = coordinator.device_info - self._coil = coil - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self.coordinator.last_update_success and self._coil.address in ( - self.coordinator.data or {} - ) - - def _async_read_coil(self, data: CoilData): - """Update state of entity based on coil data.""" - - async def _async_write_coil(self, value: int | float | str): - """Write coil and update state.""" - await self.coordinator.async_write_coil(self._coil, value) - - def _handle_coordinator_update(self) -> None: - data = self.coordinator.data.get(self._coil.address) - if data is None: - return - - self._async_read_coil(data) - self.async_write_ha_state() diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 263fd41b309..d1fdfa710a1 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -9,7 +9,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index f552d74d281..f45b2af2909 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, LOGGER, Coordinator +from .const import DOMAIN, LOGGER +from .coordinator import Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 4ab709ae947..99109ed8609 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -28,7 +28,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Coordinator from .const import ( DOMAIN, LOGGER, @@ -37,6 +36,7 @@ from .const import ( VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, ) +from .coordinator import Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py new file mode 100644 index 00000000000..853da6e5232 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -0,0 +1,234 @@ +"""The Nibe Heat Pump coordinator.""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from collections.abc import Callable, Iterable +from datetime import timedelta +from typing import Any, Generic, TypeVar + +from nibe.coil import Coil, CoilData +from nibe.connection import Connection +from nibe.exceptions import CoilNotFoundException, ReadException +from nibe.heatpump import HeatPump, Series + +from homeassistant.backports.functools import cached_property +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN, LOGGER + +_DataTypeT = TypeVar("_DataTypeT") +_ContextTypeT = TypeVar("_ContextTypeT") + + +class ContextCoordinator( + Generic[_DataTypeT, _ContextTypeT], DataUpdateCoordinator[_DataTypeT] +): + """Update coordinator with context adjustments.""" + + @cached_property + def context_callbacks(self) -> dict[_ContextTypeT, list[CALLBACK_TYPE]]: + """Return a dict of all callbacks registered for a given context.""" + callbacks: dict[_ContextTypeT, list[CALLBACK_TYPE]] = defaultdict(list) + for update_callback, context in list(self._listeners.values()): + assert isinstance(context, set) + for address in context: + callbacks[address].append(update_callback) + return callbacks + + @callback + def async_update_context_listeners(self, contexts: Iterable[_ContextTypeT]) -> None: + """Update all listeners given a set of contexts.""" + update_callbacks: set[CALLBACK_TYPE] = set() + for context in contexts: + update_callbacks.update(self.context_callbacks.get(context, [])) + + for update_callback in update_callbacks: + update_callback() + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Wrap standard function to prune cached callback database.""" + assert isinstance(context, set) + context -= {None} + release = super().async_add_listener(update_callback, context) + self.__dict__.pop("context_callbacks", None) + + @callback + def release_update(): + release() + self.__dict__.pop("context_callbacks", None) + + return release_update + + +class Coordinator(ContextCoordinator[dict[int, CoilData], int]): + """Update coordinator for nibe heat pumps.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + heatpump: HeatPump, + connection: Connection, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, LOGGER, name="Nibe Heat Pump", update_interval=timedelta(seconds=60) + ) + + self.data = {} + self.seed: dict[int, CoilData] = {} + self.connection = connection + self.heatpump = heatpump + self.task: asyncio.Task | None = None + + heatpump.subscribe(heatpump.COIL_UPDATE_EVENT, self._on_coil_update) + + def _on_coil_update(self, data: CoilData): + """Handle callback on coil updates.""" + coil = data.coil + self.data[coil.address] = data + self.seed[coil.address] = data + self.async_update_context_listeners([coil.address]) + + @property + def series(self) -> Series: + """Return which series of pump we are connected to.""" + return self.heatpump.series + + @property + def coils(self) -> list[Coil]: + """Return the full coil database.""" + return self.heatpump.get_coils() + + @property + def unique_id(self) -> str: + """Return unique id for this coordinator.""" + return self.config_entry.unique_id or self.config_entry.entry_id + + @property + def device_info(self) -> DeviceInfo: + """Return device information for the main device.""" + return DeviceInfo(identifiers={(DOMAIN, self.unique_id)}) + + def get_coil_value(self, coil: Coil) -> int | str | float | None: + """Return a coil with data and check for validity.""" + if coil_with_data := self.data.get(coil.address): + return coil_with_data.value + return None + + def get_coil_float(self, coil: Coil) -> float | None: + """Return a coil with float and check for validity.""" + if value := self.get_coil_value(coil): + return float(value) + return None + + async def async_write_coil(self, coil: Coil, value: int | float | str) -> None: + """Write coil and update state.""" + data = CoilData(coil, value) + await self.connection.write_coil(data) + + self.data[coil.address] = data + + self.async_update_context_listeners([coil.address]) + + async def async_read_coil(self, coil: Coil) -> CoilData: + """Read coil and update state using callbacks.""" + return await self.connection.read_coil(coil) + + async def _async_update_data(self) -> dict[int, CoilData]: + self.task = asyncio.current_task() + try: + return await self._async_update_data_internal() + finally: + self.task = None + + async def _async_update_data_internal(self) -> dict[int, CoilData]: + result: dict[int, CoilData] = {} + + def _get_coils() -> Iterable[Coil]: + for address in sorted(self.context_callbacks.keys()): + if seed := self.seed.pop(address, None): + self.logger.debug("Skipping seeded coil: %d", address) + result[address] = seed + continue + + try: + coil = self.heatpump.get_coil_by_address(address) + except CoilNotFoundException as exception: + self.logger.debug("Skipping missing coil: %s", exception) + continue + yield coil + + try: + async for data in self.connection.read_coils(_get_coils()): + result[data.coil.address] = data + self.seed.pop(data.coil.address, None) + except ReadException as exception: + if not result: + raise UpdateFailed(f"Failed to update: {exception}") from exception + self.logger.debug( + "Some coils failed to update, and may be unsupported: %s", exception + ) + + return result + + async def async_shutdown(self): + """Make sure a coordinator is shut down as well as it's connection.""" + await super().async_shutdown() + if self.task: + self.task.cancel() + await asyncio.wait((self.task,)) + await self.connection.stop() + + +class CoilEntity(CoordinatorEntity[Coordinator]): + """Base for coil based entities.""" + + _attr_has_entity_name = True + _attr_entity_registry_enabled_default = False + + def __init__( + self, coordinator: Coordinator, coil: Coil, entity_format: str + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator, {coil.address}) + self.entity_id = async_generate_entity_id( + entity_format, coil.name, hass=coordinator.hass + ) + self._attr_name = coil.title + self._attr_unique_id = f"{coordinator.unique_id}-{coil.address}" + self._attr_device_info = coordinator.device_info + self._coil = coil + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and self._coil.address in ( + self.coordinator.data or {} + ) + + def _async_read_coil(self, data: CoilData): + """Update state of entity based on coil data.""" + + async def _async_write_coil(self, value: int | float | str): + """Write coil and update state.""" + await self.coordinator.async_write_coil(self._coil, value) + + def _handle_coordinator_update(self) -> None: + data = self.coordinator.data.get(self._coil.address) + if data is not None: + self._async_read_coil(data) + self.async_write_ha_state() diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 8231cc65450..addfacf4faf 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -9,7 +9,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index e255ff36500..c4794cc18b7 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -9,7 +9,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index d9e89a2d56c..8c9439e6531 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -24,7 +24,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator UNIT_DESCRIPTIONS = { "°C": SensorEntityDescription( diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 16a7ef2b1f5..f55882d529c 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -11,7 +11,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index 0c606380776..c9d1d89c6ca 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -17,8 +17,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, LOGGER, Coordinator -from .const import VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE +from .const import ( + DOMAIN, + LOGGER, + VALUES_TEMPORARY_LUX_INACTIVE, + VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, +) +from .coordinator import Coordinator async def async_setup_entry( diff --git a/tests/components/nibe_heatpump/__init__.py b/tests/components/nibe_heatpump/__init__.py index d2852ec42f5..3c3db391ba8 100644 --- a/tests/components/nibe_heatpump/__init__.py +++ b/tests/components/nibe_heatpump/__init__.py @@ -1,8 +1,12 @@ """Tests for the Nibe Heat Pump integration.""" from typing import Any +from unittest.mock import AsyncMock -from nibe.heatpump import Model +from nibe.coil import Coil, CoilData +from nibe.connection import Connection +from nibe.exceptions import ReadException +from nibe.heatpump import HeatPump, Model from homeassistant.components.nibe_heatpump import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -21,7 +25,39 @@ MOCK_ENTRY_DATA = { } -async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> None: +class MockConnection(Connection): + """A mock connection class.""" + + def __init__(self) -> None: + """Initialize the mock connection.""" + self.coils: dict[int, Any] = {} + self.heatpump: HeatPump + self.start = AsyncMock() + self.stop = AsyncMock() + self.write_coil = AsyncMock() + self.verify_connectivity = AsyncMock() + self.read_product_info = AsyncMock() + + async def read_coil(self, coil: Coil, timeout: float = 0) -> CoilData: + """Read of coils.""" + if (data := self.coils.get(coil.address, None)) is None: + raise ReadException() + return CoilData(coil, data) + + async def write_coil(self, coil_data: CoilData, timeout: float = 10.0) -> None: + """Write a coil data to the heatpump.""" + + async def verify_connectivity(self): + """Verify that we have functioning communication.""" + + def mock_coil_update(self, coil_id: int, value: int | float | str | None): + """Trigger an out of band coil update.""" + coil = self.heatpump.get_coil_by_address(coil_id) + self.coils[coil_id] = value + self.heatpump.notify_coil_update(CoilData(coil, value)) + + +async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> MockConfigEntry: """Add entry and get the coordinator.""" entry = MockConfigEntry(domain=DOMAIN, title="Dummy", data=data) @@ -29,8 +65,9 @@ async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED + return entry -async def async_add_model(hass: HomeAssistant, model: Model): +async def async_add_model(hass: HomeAssistant, model: Model) -> MockConfigEntry: """Add entry of specific model.""" - await async_add_entry(hass, {**MOCK_ENTRY_DATA, "model": model.name}) + return await async_add_entry(hass, {**MOCK_ENTRY_DATA, "model": model.name}) diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index d7343eac69c..a5eb5fb012d 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -1,14 +1,18 @@ """Test configuration for Nibe Heat Pump.""" -from collections.abc import AsyncIterator, Generator, Iterable +from collections.abc import Generator from contextlib import ExitStack -from typing import Any from unittest.mock import AsyncMock, Mock, patch -from nibe.coil import Coil, CoilData -from nibe.connection import Connection -from nibe.exceptions import ReadException +from freezegun.api import FrozenDateTimeFactory +from nibe.exceptions import CoilNotFoundException import pytest +from homeassistant.core import HomeAssistant + +from . import MockConnection + +from tests.common import async_fire_time_changed + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -19,10 +23,22 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup_entry -@pytest.fixture(autouse=True, name="mock_connection_constructor") -async def fixture_mock_connection_constructor(): +@pytest.fixture(autouse=True, name="mock_connection_construct") +async def fixture_mock_connection_construct(): + """Fixture to catch constructor calls.""" + return Mock() + + +@pytest.fixture(autouse=True, name="mock_connection") +async def fixture_mock_connection(mock_connection_construct): """Make sure we have a dummy connection.""" - mock_constructor = Mock() + mock_connection = MockConnection() + + def construct(heatpump, *args, **kwargs): + mock_connection_construct(heatpump, *args, **kwargs) + mock_connection.heatpump = heatpump + return mock_connection + with ExitStack() as stack: places = [ "homeassistant.components.nibe_heatpump.config_flow.NibeGW", @@ -31,46 +47,43 @@ async def fixture_mock_connection_constructor(): "homeassistant.components.nibe_heatpump.Modbus", ] for place in places: - stack.enter_context(patch(place, new=mock_constructor)) - yield mock_constructor - - -@pytest.fixture(name="mock_connection") -def fixture_mock_connection(mock_connection_constructor: Mock): - """Make sure we have a dummy connection.""" - mock_connection = AsyncMock(spec=Connection) - mock_connection_constructor.return_value = mock_connection - return mock_connection + stack.enter_context(patch(place, new=construct)) + yield mock_connection @pytest.fixture(name="coils") -async def fixture_coils(mock_connection): +async def fixture_coils(mock_connection: MockConnection): """Return a dict with coil data.""" - coils: dict[int, Any] = {} - - async def read_coil(coil: Coil, timeout: float = 0) -> CoilData: - nonlocal coils - if (data := coils.get(coil.address, None)) is None: - raise ReadException() - return CoilData(coil, data) - - async def read_coils( - coils: Iterable[Coil], timeout: float = 0 - ) -> AsyncIterator[Coil]: - for coil in coils: - yield await read_coil(coil, timeout) - - mock_connection.read_coil = read_coil - mock_connection.read_coils = read_coils - # pylint: disable-next=import-outside-toplevel from homeassistant.components.nibe_heatpump import HeatPump get_coils_original = HeatPump.get_coils + get_coil_by_address_original = HeatPump.get_coil_by_address def get_coils(x): coils_data = get_coils_original(x) - return [coil for coil in coils_data if coil.address in coils] + return [coil for coil in coils_data if coil.address in mock_connection.coils] - with patch.object(HeatPump, "get_coils", new=get_coils): - yield coils + def get_coil_by_address(self, address): + coils_data = get_coil_by_address_original(self, address) + if coils_data.address not in mock_connection.coils: + raise CoilNotFoundException() + return coils_data + + with patch.object(HeatPump, "get_coils", new=get_coils), patch.object( + HeatPump, "get_coil_by_address", new=get_coil_by_address + ): + yield mock_connection.coils + + +@pytest.fixture(name="freezer_ticker") +async def fixture_freezer_ticker(hass: HomeAssistant, freezer: FrozenDateTimeFactory): + """Tick time and perform actions.""" + + async def ticker(delay, block=True): + freezer.tick(delay) + async_fire_time_changed(hass) + if block: + await hass.async_block_till_done() + + return ticker diff --git a/tests/components/nibe_heatpump/snapshots/test_climate.ambr b/tests/components/nibe_heatpump/snapshots/test_climate.ambr new file mode 100644 index 00000000000..3d08565e105 --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_climate.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_basic[Model.S320-s1-climate.climate_system_s1][1. initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][2. idle] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr b/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr new file mode 100644 index 00000000000..98e62a833a8 --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_invalid_coil[Sensor is available] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_invalid_coil[Sensor is not available] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_partial_refresh[1. Sensor is available] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_partial_refresh[2. Sensor is not available] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Min supply climate system 1', + 'max': 80.0, + 'min': 5.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.min_supply_climate_system_1_40035', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_partial_refresh[3. Sensor is available] + None +# --- +# name: test_pushed_update[1. initial values] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_pushed_update[2. pushed values] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_pushed_update[3. seeded values] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_pushed_update[4. final values] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }) +# --- diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index 755827fa128..d150d3f2d38 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -2,7 +2,6 @@ from typing import Any from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory from nibe.coil import CoilData from nibe.coil_groups import UNIT_COILGROUPS from nibe.heatpump import Model @@ -19,8 +18,6 @@ from homeassistant.core import HomeAssistant from . import async_add_model -from tests.common import async_fire_time_changed - @pytest.fixture(autouse=True) async def fixture_single_platform(): @@ -42,7 +39,7 @@ async def test_reset_button( model: Model, entity_id: str, coils: dict[int, Any], - freezer: FrozenDateTimeFactory, + freezer_ticker: Any, ): """Test reset button.""" @@ -61,9 +58,7 @@ async def test_reset_button( # Signal alarm coils[unit.alarm] = 100 - freezer.tick(60) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await freezer_ticker(60) state = hass.states.get(entity_id) assert state diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py new file mode 100644 index 00000000000..d4084ce8123 --- /dev/null +++ b/tests/components/nibe_heatpump/test_climate.py @@ -0,0 +1,58 @@ +"""Test the Nibe Heat Pump config flow.""" +from typing import Any +from unittest.mock import patch + +from nibe.coil_groups import CLIMATE_COILGROUPS, UNIT_COILGROUPS +from nibe.heatpump import Model +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import MockConnection, async_add_model + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.CLIMATE]): + yield + + +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.S320, "s1", "climate.climate_system_s1"), + ], +) +async def test_basic( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + climate = CLIMATE_COILGROUPS[model.series][climate_id] + unit = UNIT_COILGROUPS[model.series]["main"] + if climate.active_accessory is not None: + coils[climate.active_accessory] = "ON" + coils[climate.current] = 20.5 + coils[climate.setpoint_heat] = 21.0 + coils[climate.setpoint_cool] = 30.0 + coils[climate.mixing_valve_state] = "ON" + coils[climate.use_room_sensor] = "ON" + coils[unit.prio] = "HEAT" + coils[unit.cooling_with_room_sensor] = "ON" + + await async_add_model(hass, model) + + assert hass.states.get(entity_id) == snapshot(name="1. initial") + + mock_connection.mock_coil_update(unit.prio, "OFF") + + assert hass.states.get(entity_id) == snapshot(name="2. idle") diff --git a/tests/components/nibe_heatpump/test_config_flow.py b/tests/components/nibe_heatpump/test_config_flow.py index 22dca1fa2f3..9b03159af2f 100644 --- a/tests/components/nibe_heatpump/test_config_flow.py +++ b/tests/components/nibe_heatpump/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Nibe Heat Pump config flow.""" -from unittest.mock import Mock +from typing import Any +from unittest.mock import AsyncMock, Mock -from nibe.coil import Coil from nibe.exceptions import ( AddressInUseException, CoilNotFoundException, @@ -54,16 +54,12 @@ async def _get_connection_form( async def test_nibegw_form( - hass: HomeAssistant, mock_connection: Mock, mock_setup_entry: Mock + hass: HomeAssistant, coils: dict[int, Any], mock_setup_entry: Mock ) -> None: """Test we get the form.""" result = await _get_connection_form(hass, "nibegw") - coil_wordswap = Coil( - 48852, "modbus40-word-swap-48852", "Modbus40 Word Swap", "u8", min=0, max=1 - ) - coil_wordswap.value = "ON" - mock_connection.read_coil.return_value = coil_wordswap + coils[48852] = 1 result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_FLOW_NIBEGW_USERDATA @@ -85,16 +81,12 @@ async def test_nibegw_form( async def test_modbus_form( - hass: HomeAssistant, mock_connection: Mock, mock_setup_entry: Mock + hass: HomeAssistant, coils: dict[int, Any], mock_setup_entry: Mock ) -> None: """Test we get the form.""" result = await _get_connection_form(hass, "modbus") - coil = Coil( - 40022, "reset-alarm-40022", "Reset Alarm", "u8", min=0, max=1, write=True - ) - coil.value = "ON" - mock_connection.read_coil.return_value = coil + coils[40022] = 1 result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_FLOW_MODBUS_USERDATA @@ -113,12 +105,12 @@ async def test_modbus_form( async def test_modbus_invalid_url( - hass: HomeAssistant, mock_connection_constructor: Mock + hass: HomeAssistant, mock_connection_construct: Mock ) -> None: """Test we handle invalid auth.""" result = await _get_connection_form(hass, "modbus") - mock_connection_constructor.side_effect = ValueError() + mock_connection_construct.side_effect = ValueError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {**MOCK_FLOW_MODBUS_USERDATA, "modbus_url": "invalid://url"} ) @@ -131,6 +123,7 @@ async def test_nibegw_address_inuse(hass: HomeAssistant, mock_connection: Mock) """Test we handle invalid auth.""" result = await _get_connection_form(hass, "nibegw") + mock_connection.start = AsyncMock() mock_connection.start.side_effect = AddressInUseException() result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/nibe_heatpump/test_coordinator.py b/tests/components/nibe_heatpump/test_coordinator.py new file mode 100644 index 00000000000..474802541f2 --- /dev/null +++ b/tests/components/nibe_heatpump/test_coordinator.py @@ -0,0 +1,130 @@ +"""Test the Nibe Heat Pump config flow.""" +import asyncio +from typing import Any +from unittest.mock import patch + +from nibe.coil import Coil, CoilData +from nibe.heatpump import Model +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import MockConnection, async_add_model + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.NUMBER]): + yield + + +async def test_partial_refresh( + hass: HomeAssistant, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test that coordinator can handle partial fields.""" + coils[40031] = 10 + coils[40035] = None + coils[40039] = 10 + + await async_add_model(hass, Model.S320) + + data = hass.states.get("number.heating_offset_climate_system_1_40031") + assert data == snapshot(name="1. Sensor is available") + + data = hass.states.get("number.min_supply_climate_system_1_40035") + assert data == snapshot(name="2. Sensor is not available") + + data = hass.states.get("number.max_supply_climate_system_1_40035") + assert data == snapshot(name="3. Sensor is available") + + +async def test_invalid_coil( + hass: HomeAssistant, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, + freezer_ticker: Any, +) -> None: + """That update coordinator correctly marks entities unavailable with missing coils.""" + entity_id = "number.heating_offset_climate_system_1_40031" + coil_id = 40031 + + coils[coil_id] = 10 + await async_add_model(hass, Model.S320) + + assert hass.states.get(entity_id) == snapshot(name="Sensor is available") + + coils.pop(coil_id) + await freezer_ticker(60) + + assert hass.states.get(entity_id) == snapshot(name="Sensor is not available") + + +async def test_pushed_update( + hass: HomeAssistant, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, + mock_connection: MockConnection, + freezer_ticker: Any, +) -> None: + """Test out of band pushed value, update directly and seed the next update.""" + entity_id = "number.heating_offset_climate_system_1_40031" + coil_id = 40031 + + coils[coil_id] = 10 + await async_add_model(hass, Model.S320) + + assert hass.states.get(entity_id) == snapshot(name="1. initial values") + + mock_connection.mock_coil_update(coil_id, 20) + assert hass.states.get(entity_id) == snapshot(name="2. pushed values") + + coils[coil_id] = 30 + await freezer_ticker(60) + + assert hass.states.get(entity_id) == snapshot(name="3. seeded values") + + await freezer_ticker(60) + + assert hass.states.get(entity_id) == snapshot(name="4. final values") + + +async def test_shutdown( + hass: HomeAssistant, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + mock_connection: MockConnection, + freezer_ticker: Any, +) -> None: + """Check that shutdown, cancel a long running update.""" + coils[40031] = 10 + + entry = await async_add_model(hass, Model.S320) + mock_connection.start.assert_called_once() + + done = asyncio.Event() + hang = asyncio.Event() + + async def _read_coil_hang(coil: Coil, timeout: float = 0) -> CoilData: + try: + hang.set() + await done.wait() # infinite wait + except asyncio.CancelledError: + done.set() + + mock_connection.read_coil = _read_coil_hang + + await freezer_ticker(60, block=False) + await hang.wait() + + await hass.config_entries.async_unload(entry.entry_id) + + assert done.is_set() + mock_connection.stop.assert_called_once() From 91182603d5756c38714f4c21d5cd6e04f1522d2b Mon Sep 17 00:00:00 2001 From: dupondje Date: Mon, 6 Nov 2023 14:19:47 +0100 Subject: [PATCH 256/982] Use right equipment identifier in DSMR setup (#103494) --- homeassistant/components/dsmr/config_flow.py | 2 + tests/components/dsmr/conftest.py | 10 +++++ tests/components/dsmr/test_config_flow.py | 44 ++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index c7b9ab4e380..3b32d354766 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -53,6 +53,8 @@ class DSMRConnection: self._protocol = protocol self._telegram: dict[str, DSMRObject] = {} self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER + if dsmr_version == "5B": + self._equipment_identifier = obis_ref.BELGIUM_EQUIPMENT_IDENTIFIER if dsmr_version == "5L": self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER if dsmr_version == "Q3D": diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 67e8b724a97..01aff5ae48e 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from dsmr_parser.clients.protocol import DSMRProtocol from dsmr_parser.clients.rfxtrx_protocol import RFXtrxDSMRProtocol from dsmr_parser.obis_references import ( + BELGIUM_EQUIPMENT_IDENTIFIER, EQUIPMENT_IDENTIFIER, EQUIPMENT_IDENTIFIER_GAS, LUXEMBOURG_EQUIPMENT_IDENTIFIER, @@ -81,6 +82,15 @@ async def dsmr_connection_send_validate_fixture(hass): async def connection_factory(*args, **kwargs): """Return mocked out Asyncio classes.""" + if args[1] == "5B": + protocol.telegram = { + BELGIUM_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] + ), + EQUIPMENT_IDENTIFIER_GAS: CosemObject( + EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] + ), + } if args[1] == "5L": protocol.telegram = { LUXEMBOURG_EQUIPMENT_IDENTIFIER: CosemObject( diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 8ad7c7214a3..c4bbe9a7086 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -215,6 +215,50 @@ async def test_setup_serial_rfxtrx( assert result["data"] == {**entry_data, **SERIAL_DATA} +@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +async def test_setup_5B( + com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture +) -> None: + """Test we can setup serial.""" + port = com_port() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"type": "Serial"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "setup_serial" + assert result["errors"] == {} + + with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"port": port.device, "dsmr_version": "5B"}, + ) + await hass.async_block_till_done() + + entry_data = { + "port": port.device, + "dsmr_version": "5B", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": "123456789", + } + + assert result["type"] == "create_entry" + assert result["title"] == port.device + assert result["data"] == entry_data + + @patch("serial.tools.list_ports.comports", return_value=[com_port()]) async def test_setup_5L( com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture From b0e04ae6909a8cb49caffcf45823a5b84033255a Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 6 Nov 2023 06:48:00 -0700 Subject: [PATCH 257/982] Handle null data in WeatherFlow sensors (#103349) Co-authored-by: J. Nick Koston --- homeassistant/components/weatherflow/sensor.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index bc5d38e99e5..f3e5b8744e6 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -71,7 +71,8 @@ class WeatherFlowSensorEntityDescription( def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: """Return the parsed sensor value.""" - raw_sensor_data = getattr(device, self.key) + if (raw_sensor_data := getattr(device, self.key)) is None: + return None return self.raw_data_conv_fn(raw_sensor_data) @@ -371,14 +372,17 @@ class WeatherFlowSensorEntity(SensorEntity): return self.device.last_report return None - @property - def native_value(self) -> datetime | StateType: - """Return the state of the sensor.""" - return self.entity_description.get_native_value(self.device) + def _async_update_state(self) -> None: + """Update entity state.""" + value = self.entity_description.get_native_value(self.device) + self._attr_available = value is not None + self._attr_native_value = value + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to events.""" + self._async_update_state() for event in self.entity_description.event_subscriptions: self.async_on_remove( - self.device.on(event, lambda _: self.async_write_ha_state()) + self.device.on(event, lambda _: self._async_update_state()) ) From 5cd61a0cf4890cd79396d3da2cc10f01512e5e55 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 6 Nov 2023 15:17:28 +0100 Subject: [PATCH 258/982] Remove redundant code from the evohome integration (#103508) * remove unreachable except clause * remove uneccesary try --- homeassistant/components/evohome/__init__.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index b848bc0f02c..b47e86dd501 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -497,18 +497,6 @@ class EvoBroker: ) self.temps = None # these are now stale, will fall back to v2 temps - except KeyError as err: - _LOGGER.warning( - ( - "Unable to obtain high-precision temperatures. " - "It appears the JSON schema is not as expected, " - "so the high-precision feature will be disabled until next restart." - "Message is: %s" - ), - err, - ) - self.client_v1 = self.temps = None - else: if ( str(self.client_v1.location_id) @@ -519,7 +507,7 @@ class EvoBroker: "the v1 API's default location (there is more than one location), " "so the high-precision feature will be disabled until next restart" ) - self.client_v1 = self.temps = None + self.temps = self.client_v1 = None else: self.temps = {str(i["id"]): i["temp"] for i in temps} @@ -556,10 +544,7 @@ class EvoBroker: await self._update_v2_api_state() if self.client_v1: - try: - await self._update_v1_api_temps() - except evohomeasync.EvohomeError: - self.temps = None # these are now stale, will fall back to v2 temps + await self._update_v1_api_temps() class EvoDevice(Entity): From 54cf7010cdb6f08243377e57cb15744bb65d4b35 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 6 Nov 2023 15:45:04 +0100 Subject: [PATCH 259/982] Add ServiceValidationError and translation support (#102592) * Add ServiceValidationError * Add translation support * Extend translation support to HomeAssistantError * Add translation support for ServiceNotFound exc * Frontend translation & translation_key from caller * Improve fallback message * Set websocket_api as default translation_domain * Add MQTT ServiceValidationError exception * Follow up comments * Revert removing gueard on translation_key * Revert test changes to fix CI test * Follow up comments * Fix CI test * Follow up * Improve language * Follow up comment --- .../components/homeassistant/strings.json | 5 ++ homeassistant/components/mqtt/__init__.py | 16 ++++- homeassistant/components/mqtt/strings.json | 5 ++ .../components/websocket_api/commands.py | 46 +++++++++++++- .../components/websocket_api/connection.py | 23 ++++++- .../components/websocket_api/const.py | 1 + .../components/websocket_api/messages.py | 21 ++++++- .../components/websocket_api/strings.json | 7 +++ homeassistant/exceptions.py | 27 +++++++- script/hassfest/translations.py | 4 ++ tests/components/trace/test_websocket_api.py | 6 +- .../components/websocket_api/test_commands.py | 63 ++++++++++++++++++- 12 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/websocket_api/strings.json diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 26871522819..f14d9f8148c 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -136,5 +136,10 @@ "name": "Reload all", "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." } + }, + "exceptions": { + "service_not_found": { + "message": "Service {domain}.{service} not found." + } } } diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index be283271dee..effff9fdf12 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -24,7 +24,12 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceValidationError, + TemplateError, + Unauthorized, +) from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -246,7 +251,14 @@ async def async_check_config_schema( message, _ = conf_util._format_config_error( ex, domain, config, integration.documentation ) - raise HomeAssistantError(message) from ex + raise ServiceValidationError( + message, + translation_domain=DOMAIN, + translation_key="invalid_platform_config", + translation_placeholders={ + "domain": domain, + }, + ) from ex async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 6197e580b1d..db0ed741ac0 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -207,5 +207,10 @@ "name": "[%key:common::action::reload%]", "description": "Reloads MQTT entities from the YAML-configuration." } + }, + "exceptions": { + "invalid_platform_config": { + "message": "Reloading YAML config for manually configured MQTT `{domain}` failed. See logs for more details." + } } } diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 369eca38925..471bbc4745a 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -22,6 +22,7 @@ from homeassistant.core import Context, Event, HomeAssistant, State, callback from homeassistant.exceptions import ( HomeAssistantError, ServiceNotFound, + ServiceValidationError, TemplateError, Unauthorized, ) @@ -238,14 +239,53 @@ async def handle_call_service( connection.send_result(msg["id"], {"context": context}) except ServiceNotFound as err: if err.domain == msg["domain"] and err.service == msg["service"]: - connection.send_error(msg["id"], const.ERR_NOT_FOUND, "Service not found.") + connection.send_error( + msg["id"], + const.ERR_NOT_FOUND, + f"Service {err.domain}.{err.service} not found.", + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) else: - connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, str(err)) + # The called service called another service which does not exist + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + f"Service {err.domain}.{err.service} called service " + f"{msg['domain']}.{msg['service']} which was not found.", + translation_domain=const.DOMAIN, + translation_key="child_service_not_found", + translation_placeholders={ + "domain": err.domain, + "service": err.service, + "child_domain": msg["domain"], + "child_service": msg["service"], + }, + ) except vol.Invalid as err: connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + except ServiceValidationError as err: + connection.logger.error(err) + connection.logger.debug("", exc_info=err) + connection.send_error( + msg["id"], + const.ERR_SERVICE_VALIDATION_ERROR, + f"Validation error: {err}", + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) except HomeAssistantError as err: connection.logger.exception(err) - connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, str(err)) + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) except Exception as err: # pylint: disable=broad-except connection.logger.exception(err) connection.send_error(msg["id"], const.ERR_UNKNOWN_ERROR, str(err)) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 1dbda62ab95..4581b3be773 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -134,9 +134,26 @@ class ActiveConnection: self.send_message(messages.event_message(msg_id, event)) @callback - def send_error(self, msg_id: int, code: str, message: str) -> None: - """Send a error message.""" - self.send_message(messages.error_message(msg_id, code, message)) + def send_error( + self, + msg_id: int, + code: str, + message: str, + translation_key: str | None = None, + translation_domain: str | None = None, + translation_placeholders: dict[str, Any] | None = None, + ) -> None: + """Send an error message.""" + self.send_message( + messages.error_message( + msg_id, + code, + message, + translation_key=translation_key, + translation_domain=translation_domain, + translation_placeholders=translation_placeholders, + ) + ) @callback def async_handle_binary(self, handler_id: int, payload: bytes) -> None: diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 4b9a0943d9a..9a44f80a5c8 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -32,6 +32,7 @@ ERR_NOT_ALLOWED: Final = "not_allowed" ERR_NOT_FOUND: Final = "not_found" ERR_NOT_SUPPORTED: Final = "not_supported" ERR_HOME_ASSISTANT_ERROR: Final = "home_assistant_error" +ERR_SERVICE_VALIDATION_ERROR: Final = "service_validation_error" ERR_UNKNOWN_COMMAND: Final = "unknown_command" ERR_UNKNOWN_ERROR: Final = "unknown_error" ERR_UNAUTHORIZED: Final = "unauthorized" diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 12e649219bc..34ca6886b5e 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -65,12 +65,29 @@ def construct_result_message(iden: int, payload: str) -> str: return f'{{"id":{iden},"type":"result","success":true,"result":{payload}}}' -def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: +def error_message( + iden: int | None, + code: str, + message: str, + translation_key: str | None = None, + translation_domain: str | None = None, + translation_placeholders: dict[str, Any] | None = None, +) -> dict[str, Any]: """Return an error result message.""" + error_payload: dict[str, Any] = { + "code": code, + "message": message, + } + # In case `translation_key` is `None` we do not set it, nor the + # `translation`_placeholders` and `translation_domain`. + if translation_key is not None: + error_payload["translation_key"] = translation_key + error_payload["translation_placeholders"] = translation_placeholders + error_payload["translation_domain"] = translation_domain return { "id": iden, **BASE_ERROR_MESSAGE, - "error": {"code": code, "message": message}, + "error": error_payload, } diff --git a/homeassistant/components/websocket_api/strings.json b/homeassistant/components/websocket_api/strings.json new file mode 100644 index 00000000000..10b95637b6b --- /dev/null +++ b/homeassistant/components/websocket_api/strings.json @@ -0,0 +1,7 @@ +{ + "exceptions": { + "child_service_not_found": { + "message": "Service {domain}.{service} called service {child_domain}.{child_service} which was not found." + } + } +} diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 2946c8c3743..262b0e338ff 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -12,6 +12,23 @@ if TYPE_CHECKING: class HomeAssistantError(Exception): """General Home Assistant exception occurred.""" + def __init__( + self, + *args: object, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + """Initialize exception.""" + super().__init__(*args) + self.translation_domain = translation_domain + self.translation_key = translation_key + self.translation_placeholders = translation_placeholders + + +class ServiceValidationError(HomeAssistantError): + """A validation exception occurred when calling a service.""" + class InvalidEntityFormatError(HomeAssistantError): """When an invalid formatted entity is encountered.""" @@ -165,13 +182,19 @@ class ServiceNotFound(HomeAssistantError): def __init__(self, domain: str, service: str) -> None: """Initialize error.""" - super().__init__(self, f"Service {domain}.{service} not found") + super().__init__( + self, + f"Service {domain}.{service} not found.", + translation_domain="homeassistant", + translation_key="service_not_found", + translation_placeholders={"domain": domain, "service": service}, + ) self.domain = domain self.service = service def __str__(self) -> str: """Return string representation.""" - return f"Unable to find service {self.domain}.{self.service}" + return f"Service {self.domain}.{self.service} not found." class MaxLengthExceeded(HomeAssistantError): diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 4483aacd804..950eeb827ba 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -328,6 +328,10 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: ), slug_validator=cv.slug, ), + vol.Optional("exceptions"): cv.schema_with_slug_keys( + {vol.Optional("message"): translation_value_validator}, + slug_validator=cv.slug, + ), vol.Optional("services"): cv.schema_with_slug_keys( { vol.Required("name"): translation_value_validator, diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index 1041208fa61..1197719328b 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -205,7 +205,7 @@ async def test_get_trace( _assert_raw_config(domain, sun_config, trace) assert trace["blueprint_inputs"] is None assert trace["context"] - assert trace["error"] == "Unable to find service test.automation" + assert trace["error"] == "Service test.automation not found." assert trace["state"] == "stopped" assert trace["script_execution"] == "error" assert trace["item_id"] == "sun" @@ -893,7 +893,7 @@ async def test_list_traces( assert len(_find_traces(response["result"], domain, "sun")) == 1 trace = _find_traces(response["result"], domain, "sun")[0] assert trace["last_step"] == last_step[0].format(prefix=prefix) - assert trace["error"] == "Unable to find service test.automation" + assert trace["error"] == "Service test.automation not found." assert trace["state"] == "stopped" assert trace["script_execution"] == script_execution[0] assert trace["timestamp"] @@ -1632,7 +1632,7 @@ async def test_trace_blueprint_automation( assert trace["config"]["id"] == "sun" assert trace["blueprint_inputs"] == sun_config assert trace["context"] - assert trace["error"] == "Unable to find service test.automation" + assert trace["error"] == "Service test.automation not found." assert trace["state"] == "stopped" assert trace["script_execution"] == "error" assert trace["item_id"] == "sun" diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index f200c44acca..34424545666 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -19,7 +19,7 @@ from homeassistant.components.websocket_api.auth import ( from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import Context, HomeAssistant, State, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.loader import async_get_integration @@ -343,6 +343,13 @@ async def test_call_service_not_found( assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_NOT_FOUND + assert msg["error"]["message"] == "Service domain_test.test_service not found." + assert msg["error"]["translation_placeholders"] == { + "domain": "domain_test", + "service": "test_service", + } + assert msg["error"]["translation_key"] == "service_not_found" + assert msg["error"]["translation_domain"] == "homeassistant" async def test_call_service_child_not_found( @@ -370,6 +377,18 @@ async def test_call_service_child_not_found( assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_HOME_ASSISTANT_ERROR + assert ( + msg["error"]["message"] == "Service non.existing called service " + "domain_test.test_service which was not found." + ) + assert msg["error"]["translation_placeholders"] == { + "domain": "non", + "service": "existing", + "child_domain": "domain_test", + "child_service": "test_service", + } + assert msg["error"]["translation_key"] == "child_service_not_found" + assert msg["error"]["translation_domain"] == "websocket_api" async def test_call_service_schema_validation_error( @@ -450,10 +469,26 @@ async def test_call_service_error( @callback def ha_error_call(_): - raise HomeAssistantError("error_message") + raise HomeAssistantError( + "error_message", + translation_domain="test", + translation_key="custom_error", + translation_placeholders={"option": "bla"}, + ) hass.services.async_register("domain_test", "ha_error", ha_error_call) + @callback + def service_error_call(_): + raise ServiceValidationError( + "error_message", + translation_domain="test", + translation_key="custom_error", + translation_placeholders={"option": "bla"}, + ) + + hass.services.async_register("domain_test", "service_error", service_error_call) + async def unknown_error_call(_): raise ValueError("value_error") @@ -474,18 +509,40 @@ async def test_call_service_error( assert msg["success"] is False assert msg["error"]["code"] == "home_assistant_error" assert msg["error"]["message"] == "error_message" + assert msg["error"]["translation_placeholders"] == {"option": "bla"} + assert msg["error"]["translation_key"] == "custom_error" + assert msg["error"]["translation_domain"] == "test" await websocket_client.send_json( { "id": 6, "type": "call_service", "domain": "domain_test", + "service": "service_error", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == "service_validation_error" + assert msg["error"]["message"] == "Validation error: error_message" + assert msg["error"]["translation_placeholders"] == {"option": "bla"} + assert msg["error"]["translation_key"] == "custom_error" + assert msg["error"]["translation_domain"] == "test" + + await websocket_client.send_json( + { + "id": 7, + "type": "call_service", + "domain": "domain_test", "service": "unknown_error", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 + assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" From ad22a114859913494cbbaa879d526a137a604001 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 6 Nov 2023 14:50:28 +0000 Subject: [PATCH 260/982] Update systembridgeconnector to 3.9.4 (#103425) * Update systembridgeconnector to 3.9.2 * Update systembridgeconnector to version 3.9.3 * Update systembridgeconnector to version 3.9.4 * Fix import --- .../components/system_bridge/__init__.py | 8 +++--- .../components/system_bridge/config_flow.py | 4 +-- .../components/system_bridge/coordinator.py | 28 +++++++++---------- .../components/system_bridge/manifest.json | 2 +- .../components/system_bridge/media_player.py | 5 +--- .../components/system_bridge/media_source.py | 4 +-- .../components/system_bridge/notify.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../system_bridge/test_config_flow.py | 7 +++-- 10 files changed, 31 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 843640695e4..9eec64ec5f6 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -9,11 +9,11 @@ from systembridgeconnector.exceptions import ( ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.models.keyboard_key import KeyboardKey -from systembridgeconnector.models.keyboard_text import KeyboardText -from systembridgeconnector.models.open_path import OpenPath -from systembridgeconnector.models.open_url import OpenUrl from systembridgeconnector.version import SUPPORTED_VERSION, Version +from systembridgemodels.keyboard_key import KeyboardKey +from systembridgemodels.keyboard_text import KeyboardText +from systembridgemodels.open_path import OpenPath +from systembridgemodels.open_url import OpenUrl import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index a7dea5d6ab2..a001f22c9e8 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -11,9 +11,9 @@ from systembridgeconnector.exceptions import ( ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.models.get_data import GetData -from systembridgeconnector.models.system import System from systembridgeconnector.websocket_client import WebSocketClient +from systembridgemodels.get_data import GetData +from systembridgemodels.system import System import voluptuous as vol from homeassistant import config_entries, exceptions diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index a4b016d49bd..938b7d79b83 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -13,21 +13,21 @@ from systembridgeconnector.exceptions import ( ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.models.battery import Battery -from systembridgeconnector.models.cpu import Cpu -from systembridgeconnector.models.disk import Disk -from systembridgeconnector.models.display import Display -from systembridgeconnector.models.get_data import GetData -from systembridgeconnector.models.gpu import Gpu -from systembridgeconnector.models.media import Media -from systembridgeconnector.models.media_directories import MediaDirectories -from systembridgeconnector.models.media_files import File as MediaFile, MediaFiles -from systembridgeconnector.models.media_get_file import MediaGetFile -from systembridgeconnector.models.media_get_files import MediaGetFiles -from systembridgeconnector.models.memory import Memory -from systembridgeconnector.models.register_data_listener import RegisterDataListener -from systembridgeconnector.models.system import System from systembridgeconnector.websocket_client import WebSocketClient +from systembridgemodels.battery import Battery +from systembridgemodels.cpu import Cpu +from systembridgemodels.disk import Disk +from systembridgemodels.display import Display +from systembridgemodels.get_data import GetData +from systembridgemodels.gpu import Gpu +from systembridgemodels.media import Media +from systembridgemodels.media_directories import MediaDirectories +from systembridgemodels.media_files import File as MediaFile, MediaFiles +from systembridgemodels.media_get_file import MediaGetFile +from systembridgemodels.media_get_files import MediaGetFiles +from systembridgemodels.memory import Memory +from systembridgemodels.register_data_listener import RegisterDataListener +from systembridgemodels.system import System from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 64590ecb96f..c0c6ee32869 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.8.4"], + "requirements": ["systembridgeconnector==3.9.4"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index fea0837497d..ea9e8ab070d 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -4,10 +4,7 @@ from __future__ import annotations import datetime as dt from typing import Final -from systembridgeconnector.models.media_control import ( - Action as MediaAction, - MediaControl, -) +from systembridgemodels.media_control import Action as MediaAction, MediaControl from homeassistant.components.media_player import ( MediaPlayerDeviceClass, diff --git a/homeassistant/components/system_bridge/media_source.py b/homeassistant/components/system_bridge/media_source.py index 3186d74b15a..3423946f637 100644 --- a/homeassistant/components/system_bridge/media_source.py +++ b/homeassistant/components/system_bridge/media_source.py @@ -1,8 +1,8 @@ """System Bridge Media Source Implementation.""" from __future__ import annotations -from systembridgeconnector.models.media_directories import MediaDirectories -from systembridgeconnector.models.media_files import File as MediaFile, MediaFiles +from systembridgemodels.media_directories import MediaDirectories +from systembridgemodels.media_files import File as MediaFile, MediaFiles from homeassistant.components.media_player import MediaClass from homeassistant.components.media_source import MEDIA_CLASS_MAP, MEDIA_MIME_TYPES diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py index 1ad071bf78f..f8c00789ae5 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from systembridgeconnector.models.notification import Notification +from systembridgemodels.notification import Notification from homeassistant.components.notify import ( ATTR_DATA, diff --git a/requirements_all.txt b/requirements_all.txt index afadfe86607..c88824011bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2536,7 +2536,7 @@ switchbot-api==1.2.1 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.8.4 +systembridgeconnector==3.9.4 # homeassistant.components.tailscale tailscale==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c4e0bdb52f..7d3ab02287b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1890,7 +1890,7 @@ surepy==0.8.0 switchbot-api==1.2.1 # homeassistant.components.system_bridge -systembridgeconnector==3.8.4 +systembridgeconnector==3.9.4 # homeassistant.components.tailscale tailscale==0.6.0 diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 56afc87c3bb..39ecc95d89e 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -3,14 +3,15 @@ import asyncio from ipaddress import ip_address from unittest.mock import patch -from systembridgeconnector.const import MODEL_SYSTEM, TYPE_DATA_UPDATE +from systembridgeconnector.const import TYPE_DATA_UPDATE from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.models.response import Response -from systembridgeconnector.models.system import LastUpdated, System +from systembridgemodels.const import MODEL_SYSTEM +from systembridgemodels.response import Response +from systembridgemodels.system import LastUpdated, System from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf From 08b43c44770a7ad87fdc5b49a484452f1294b631 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Nov 2023 18:12:58 +0100 Subject: [PATCH 261/982] Add device fixtures and tests for HomeWizard Energy Watersensor (#103383) Co-authored-by: Duco Sebel <74970928+DCSBL@users.noreply.github.com> --- .../homewizard/fixtures/HWE-WTR/data.json | 46 ++ .../homewizard/fixtures/HWE-WTR/device.json | 7 + .../snapshots/test_diagnostics.ambr | 67 +++ .../homewizard/snapshots/test_sensor.ambr | 563 ++++++++++++++---- tests/components/homewizard/test_button.py | 2 +- .../components/homewizard/test_diagnostics.py | 10 +- tests/components/homewizard/test_number.py | 11 +- tests/components/homewizard/test_sensor.py | 109 +++- tests/components/homewizard/test_switch.py | 22 + 9 files changed, 695 insertions(+), 142 deletions(-) create mode 100644 tests/components/homewizard/fixtures/HWE-WTR/data.json create mode 100644 tests/components/homewizard/fixtures/HWE-WTR/device.json diff --git a/tests/components/homewizard/fixtures/HWE-WTR/data.json b/tests/components/homewizard/fixtures/HWE-WTR/data.json new file mode 100644 index 00000000000..169528abef4 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-WTR/data.json @@ -0,0 +1,46 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 84, + "smr_version": null, + "meter_model": null, + "unique_meter_id": null, + "active_tariff": null, + "total_power_import_kwh": null, + "total_power_import_t1_kwh": null, + "total_power_import_t2_kwh": null, + "total_power_import_t3_kwh": null, + "total_power_import_t4_kwh": null, + "total_power_export_kwh": null, + "total_power_export_t1_kwh": null, + "total_power_export_t2_kwh": null, + "total_power_export_t3_kwh": null, + "total_power_export_t4_kwh": null, + "active_power_w": null, + "active_power_l1_w": null, + "active_power_l2_w": null, + "active_power_l3_w": null, + "active_voltage_l1_v": null, + "active_voltage_l2_v": null, + "active_voltage_l3_v": null, + "active_current_l1_a": null, + "active_current_l2_a": null, + "active_current_l3_a": null, + "active_frequency_hz": null, + "voltage_sag_l1_count": null, + "voltage_sag_l2_count": null, + "voltage_sag_l3_count": null, + "voltage_swell_l1_count": null, + "voltage_swell_l2_count": null, + "voltage_swell_l3_count": null, + "any_power_fail_count": null, + "long_power_fail_count": null, + "active_power_average_w": null, + "monthly_power_peak_w": null, + "monthly_power_peak_timestamp": null, + "total_gas_m3": null, + "gas_timestamp": null, + "gas_unique_id": null, + "active_liter_lpm": 0, + "total_liter_m3": 17.014, + "external_devices": null +} diff --git a/tests/components/homewizard/fixtures/HWE-WTR/device.json b/tests/components/homewizard/fixtures/HWE-WTR/device.json new file mode 100644 index 00000000000..d33e6045299 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-WTR/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-WTR", + "product_name": "Watermeter", + "serial": "3c39e7aabbcc", + "firmware_version": "2.03", + "api_version": "v1" +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 50ace69963d..861fae48720 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -141,6 +141,73 @@ }), }) # --- +# name: test_diagnostics[HWE-WTR] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': None, + 'active_liter_lpm': 0, + 'active_power_average_w': None, + 'active_power_l1_w': None, + 'active_power_l2_w': None, + 'active_power_l3_w': None, + 'active_power_w': None, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': None, + 'total_energy_export_t1_kwh': None, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': None, + 'total_energy_import_t1_kwh': None, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': 17.014, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 84, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.03', + 'product_name': 'Watermeter', + 'product_type': 'HWE-WTR', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': None, + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- # name: test_diagnostics[SDM230] dict({ 'data': dict({ diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 176e10d219c..a20c85fd544 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_average_demand:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -31,7 +31,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_average_demand:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -62,7 +62,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_average_demand:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -76,7 +76,7 @@ 'state': '123.0', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -108,7 +108,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -141,7 +141,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -156,7 +156,7 @@ 'state': '-4', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -188,7 +188,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -221,7 +221,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -236,7 +236,7 @@ 'state': '2', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -268,7 +268,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -301,7 +301,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -316,7 +316,7 @@ 'state': '0', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_frequency:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -348,7 +348,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_frequency:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_frequency:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -381,7 +381,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_frequency:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_frequency:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', @@ -396,7 +396,7 @@ 'state': '50', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -428,7 +428,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -464,7 +464,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -479,7 +479,7 @@ 'state': '-123', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -511,7 +511,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -547,7 +547,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -562,7 +562,7 @@ 'state': '-123', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -594,7 +594,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -630,7 +630,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -645,7 +645,7 @@ 'state': '456', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -677,7 +677,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -713,7 +713,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -728,7 +728,7 @@ 'state': '123.456', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_tariff:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -760,7 +760,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_tariff:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_tariff:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -798,7 +798,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_tariff:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_tariff:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -818,7 +818,7 @@ 'state': '2', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -850,7 +850,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -883,7 +883,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -898,7 +898,7 @@ 'state': '230.111', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -930,7 +930,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -963,7 +963,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -978,7 +978,7 @@ 'state': '230.222', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1010,7 +1010,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1043,7 +1043,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1058,7 +1058,7 @@ 'state': '230.333', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_water_usage:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1090,7 +1090,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_water_usage:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_water_usage:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1123,7 +1123,7 @@ 'unit_of_measurement': 'l/min', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_active_water_usage:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_water_usage:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Active water usage', @@ -1138,7 +1138,7 @@ 'state': '12.345', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1170,7 +1170,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_dsmr_version:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1201,7 +1201,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_dsmr_version:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device DSMR version', @@ -1214,7 +1214,7 @@ 'state': '50', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1246,7 +1246,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1277,7 +1277,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Gas meter identifier', @@ -1290,7 +1290,7 @@ 'state': '01FFEEDDCCBBAA99887766554433221100', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1322,7 +1322,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1353,7 +1353,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Long power failures detected', @@ -1366,7 +1366,7 @@ 'state': '5', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1398,7 +1398,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1429,7 +1429,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -1443,7 +1443,7 @@ 'state': '1111.0', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1475,7 +1475,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_power_failures_detected:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1506,7 +1506,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_power_failures_detected:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Power failures detected', @@ -1519,7 +1519,7 @@ 'state': '4', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1551,7 +1551,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1582,7 +1582,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Smart meter identifier', @@ -1595,7 +1595,7 @@ 'state': '00112233445566778899AABBCCDDEEFF', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_model:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1627,7 +1627,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_model:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1658,7 +1658,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_smart_meter_model:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Smart meter model', @@ -1671,7 +1671,7 @@ 'state': 'ISKRA 2M550T-101', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1703,7 +1703,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1736,7 +1736,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -1751,7 +1751,7 @@ 'state': '13086.777', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1783,7 +1783,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1816,7 +1816,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -1831,7 +1831,7 @@ 'state': '4321.333', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1863,7 +1863,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1896,7 +1896,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -1911,7 +1911,7 @@ 'state': '8765.444', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -1943,7 +1943,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1976,7 +1976,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -1991,7 +1991,7 @@ 'state': '8765.444', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2023,7 +2023,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2056,7 +2056,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2071,7 +2071,7 @@ 'state': '8765.444', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2103,7 +2103,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2136,7 +2136,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2151,7 +2151,7 @@ 'state': '13779.338', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2183,7 +2183,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2216,7 +2216,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2231,7 +2231,7 @@ 'state': '10830.511', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2263,7 +2263,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2296,7 +2296,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2311,7 +2311,7 @@ 'state': '2948.827', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2343,7 +2343,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2376,7 +2376,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2391,7 +2391,7 @@ 'state': '2948.827', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2423,7 +2423,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2456,7 +2456,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -2471,7 +2471,7 @@ 'state': '2948.827', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_gas:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2503,7 +2503,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_gas:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_gas:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2536,7 +2536,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_gas:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_gas:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'gas', @@ -2551,7 +2551,7 @@ 'state': '1122.333', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_water_usage:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2583,7 +2583,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_water_usage:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2616,7 +2616,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_total_water_usage:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', @@ -2632,7 +2632,7 @@ 'state': '1234.567', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2664,7 +2664,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2695,7 +2695,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 1', @@ -2708,7 +2708,7 @@ 'state': '1', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2740,7 +2740,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2771,7 +2771,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 2', @@ -2784,7 +2784,7 @@ 'state': '2', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2816,7 +2816,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2847,7 +2847,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 3', @@ -2860,7 +2860,7 @@ 'state': '3', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2892,7 +2892,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2923,7 +2923,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 1', @@ -2936,7 +2936,7 @@ 'state': '4', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -2968,7 +2968,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2999,7 +2999,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 2', @@ -3012,7 +3012,7 @@ 'state': '5', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3044,7 +3044,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3075,7 +3075,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 3', @@ -3088,7 +3088,7 @@ 'state': '6', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3120,7 +3120,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3151,7 +3151,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', @@ -3164,7 +3164,7 @@ 'state': 'My Wi-Fi', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:device-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3196,7 +3196,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:entity-registry] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3229,7 +3229,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors_p1_meter[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:state] +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', @@ -3244,3 +3244,320 @@ 'state': '100', }) # --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_active_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_active_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Active water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_active_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Active water usage', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_active_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'icon': 'mdi:gauge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '17.014', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '84', + }) +# --- diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index d87cde87616..a7b7d0917e6 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -17,7 +17,7 @@ pytestmark = [ ] -@pytest.mark.parametrize("device_fixture", ["SDM230"]) +@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230"]) async def test_identify_button_entity_not_loaded_when_not_available( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 127ffbdc0f5..ab7432e8dbf 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -10,7 +10,15 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -@pytest.mark.parametrize("device_fixture", ["HWE-P1", "HWE-SKT", "SDM230"]) +@pytest.mark.parametrize( + "device_fixture", + [ + "HWE-P1", + "HWE-SKT", + "HWE-WTR", + "SDM230", + ], +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index a3f4da0fdba..83ab183524e 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -16,8 +16,11 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed +pytestmark = [ + pytest.mark.usefixtures("init_integration"), +] + -@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) async def test_number_entities( hass: HomeAssistant, @@ -86,3 +89,9 @@ async def test_number_entities( }, blocking=True, ) + + +@pytest.mark.parametrize("device_fixture", ["HWE-WTR"]) +async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: + """Does not load button when device has no support for it.""" + assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 971047a14ff..52e3eaa8263 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -68,10 +68,19 @@ pytestmark = [ "sensor.device_active_water_usage", "sensor.device_total_water_usage", ], - ) + ), + ( + "HWE-WTR", + [ + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.device_active_water_usage", + "sensor.device_total_water_usage", + ], + ), ], ) -async def test_sensors_p1_meter( +async def test_sensors( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -92,27 +101,39 @@ async def test_sensors_p1_meter( @pytest.mark.parametrize( - "entity_id", + ("device_fixture", "entity_ids"), [ - "sensor.device_wi_fi_strength", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", + ( + "HWE-P1", + [ + "sensor.device_wi_fi_strength", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + ], + ), + ( + "HWE-WTR", + [ + "sensor.device_wi_fi_strength", + ], + ), ], ) async def test_disabled_by_default_sensors( - hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] ) -> None: """Test the disabled by default sensors.""" - assert not hass.states.get(entity_id) + for entity_id in entity_ids: + assert not hass.states.get(entity_id) - assert (entry := entity_registry.async_get(entity_id)) - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @pytest.mark.parametrize("device_fixture", ["HWE-P1-unused-exports"]) @@ -155,3 +176,59 @@ async def test_sensors_unreachable( assert (state := hass.states.get(state.entity_id)) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-WTR", + [ + "sensor.device_dsmr_version", + "sensor.device_smart_meter_model", + "sensor.device_smart_meter_identifier", + "sensor.device_active_tariff", + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_long_power_failures_detected", + "sensor.device_active_average_demand", + "sensor.device_peak_demand_current_month", + "sensor.device_total_gas", + "sensor.device_gas_meter_identifier", + ], + ), + ], +) +async def test_entities_not_created_for_device( + hass: HomeAssistant, + entity_ids: list[str], +) -> None: + """Ensures entities for a specific device are not created.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index c63c1c864af..0571664ec16 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -26,6 +26,28 @@ pytestmark = [ ] +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-WTR", + [ + "switch.device", + "switch.device_switch_lock", + "switch.device_cloud_connection", + ], + ), + ], +) +async def test_entities_not_created_for_device( + hass: HomeAssistant, + entity_ids: list[str], +) -> None: + """Ensures entities for a specific device are not created.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + @pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) @pytest.mark.parametrize( ("entity_id", "method", "parameter"), From 8371fe520e5bf9297063f80fc775b6f9af12329e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Nov 2023 20:24:01 +0100 Subject: [PATCH 262/982] Update elgato to 5.1.0 (#103530) --- homeassistant/components/elgato/diagnostics.py | 4 ++-- homeassistant/components/elgato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/elgato/conftest.py | 8 ++++---- .../elgato/snapshots/test_diagnostics.ambr | 18 +++++++----------- 6 files changed, 16 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/elgato/diagnostics.py b/homeassistant/components/elgato/diagnostics.py index c63290f736f..46730b8f005 100644 --- a/homeassistant/components/elgato/diagnostics.py +++ b/homeassistant/components/elgato/diagnostics.py @@ -16,6 +16,6 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: ElgatoDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return { - "info": coordinator.data.info.dict(), - "state": coordinator.data.state.dict(), + "info": coordinator.data.info.to_dict(), + "state": coordinator.data.state.to_dict(), } diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 49340f028d0..033a2567bb4 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["elgato==5.0.0"], + "requirements": ["elgato==5.1.0"], "zeroconf": ["_elg._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c88824011bb..091007590cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -734,7 +734,7 @@ ecoaliface==0.4.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.0.0 +elgato==5.1.0 # homeassistant.components.eliqonline eliqonline==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d3ab02287b..9c0225162ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -596,7 +596,7 @@ easyenergy==0.3.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.0.0 +elgato==5.1.0 # homeassistant.components.elkm1 elkm1-lib==2.2.6 diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index bd1b4242ede..e8be6a4810b 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -70,20 +70,20 @@ def mock_elgato( "homeassistant.components.elgato.config_flow.Elgato", new=elgato_mock ): elgato = elgato_mock.return_value - elgato.info.return_value = Info.parse_raw( + elgato.info.return_value = Info.from_json( load_fixture(f"{device_fixtures}/info.json", DOMAIN) ) - elgato.state.return_value = State.parse_raw( + elgato.state.return_value = State.from_json( load_fixture(f"{device_fixtures}/{state_variant}.json", DOMAIN) ) - elgato.settings.return_value = Settings.parse_raw( + elgato.settings.return_value = Settings.from_json( load_fixture(f"{device_fixtures}/settings.json", DOMAIN) ) # This may, or may not, be a battery-powered device if get_fixture_path(f"{device_fixtures}/battery.json", DOMAIN).exists(): elgato.has_battery.return_value = True - elgato.battery.return_value = BatteryInfo.parse_raw( + elgato.battery.return_value = BatteryInfo.from_json( load_fixture(f"{device_fixtures}/battery.json", DOMAIN) ) else: diff --git a/tests/components/elgato/snapshots/test_diagnostics.ambr b/tests/components/elgato/snapshots/test_diagnostics.ambr index a22dc07f717..c3996c8dd45 100644 --- a/tests/components/elgato/snapshots/test_diagnostics.ambr +++ b/tests/components/elgato/snapshots/test_diagnostics.ambr @@ -2,23 +2,19 @@ # name: test_diagnostics dict({ 'info': dict({ - 'display_name': 'Frenck', + 'displayName': 'Frenck', 'features': list([ 'lights', ]), - 'firmware_build_number': 192, - 'firmware_version': '1.0.3', - 'hardware_board_type': 53, - 'mac_address': None, - 'product_name': 'Elgato Key Light', - 'serial_number': 'CN11A1A00001', - 'wifi': None, + 'firmwareBuildNumber': 192, + 'firmwareVersion': '1.0.3', + 'hardwareBoardType': 53, + 'productName': 'Elgato Key Light', + 'serialNumber': 'CN11A1A00001', }), 'state': dict({ 'brightness': 21, - 'hue': None, - 'on': True, - 'saturation': None, + 'on': 1, 'temperature': 297, }), }) From 57a3c7073154a288f5921e607921f3bc1dd8901e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Nov 2023 19:30:01 +0000 Subject: [PATCH 263/982] Bump nextdns to version 2.0.1 (#103531) --- homeassistant/components/nextdns/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index ddd2e400dab..725ce1b9201 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==2.0.0"] + "requirements": ["nextdns==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 091007590cb..3420c725d33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==2.0.0 +nextdns==2.0.1 # homeassistant.components.nibe_heatpump nibe==2.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c0225162ae..87d08880ca8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==2.0.0 +nextdns==2.0.1 # homeassistant.components.nibe_heatpump nibe==2.4.0 From 570b4ccb4b53961c68822ef6e0422487536b87cb Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Nov 2023 19:31:12 +0000 Subject: [PATCH 264/982] Bump gios to version 3.2.1 (#103533) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index fece0b09f60..18ea52fc15f 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==3.2.0"] + "requirements": ["gios==3.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3420c725d33..4fd28a3f17e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -885,7 +885,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.2.0 +gios==3.2.1 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87d08880ca8..7bb9808357b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -707,7 +707,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.2.0 +gios==3.2.1 # homeassistant.components.glances glances-api==0.4.3 From 408e977b17f2f9fa25c9f79ab5a173c56d674b5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Nov 2023 13:48:47 -0600 Subject: [PATCH 265/982] Try to avoid re-parsing the content-type in hassio ingress if possible (#103477) Co-authored-by: Stefan Agner Co-authored-by: Franck Nijhof --- homeassistant/components/hassio/ingress.py | 9 ++++++-- tests/components/hassio/test_ingress.py | 24 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index b8c5873b967..345c14163f5 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -169,6 +169,11 @@ class HassIOIngress(HomeAssistantView): headers = _response_header(result) content_length_int = 0 content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED) + # Avoid parsing content_type in simple cases for better performance + if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): + content_type = (maybe_content_type.partition(";"))[0].strip() + else: + content_type = result.content_type # Simple request if result.status in (204, 304) or ( content_length is not UNDEFINED @@ -180,11 +185,11 @@ class HassIOIngress(HomeAssistantView): simple_response = web.Response( headers=headers, status=result.status, - content_type=result.content_type, + content_type=content_type, body=body, ) if content_length_int > MIN_COMPRESSED_SIZE and should_compress( - simple_response.content_type + content_type or simple_response.content_type ): simple_response.enable_compression() await simple_response.prepare(request) diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 3eda10b1514..c8255ac0496 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -427,6 +427,30 @@ async def test_ingress_request_not_compressed( assert "Content-Encoding" not in resp.headers +async def test_ingress_request_with_charset_in_content_type( + hassio_noauth_client, aioclient_mock: AiohttpClientMocker +) -> None: + """Test ingress passes content type.""" + body = b"this_is_long_enough_to_be_compressed" * 100 + aioclient_mock.get( + "http://127.0.0.1/ingress/core/x.any", + data=body, + headers={ + "Content-Length": len(body), + "Content-Type": "text/html; charset=utf-8", + }, + ) + + resp = await hassio_noauth_client.get( + "/api/hassio_ingress/core/x.any", + headers={"X-Test-Header": "beer", "Accept-Encoding": "gzip, deflate"}, + ) + + # Check we got right response + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Type"] == "text/html" + + @pytest.mark.parametrize( "content_type", [ From 471fb4bce3271d7b510c049757ca24328c84a358 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 6 Nov 2023 20:01:25 +0000 Subject: [PATCH 266/982] Update systembridgeconnector version to 3.9.5 (#103515) --- homeassistant/components/system_bridge/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index c0c6ee32869..1bc00aee4f5 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.9.4"], + "requirements": ["systembridgeconnector==3.9.5"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 4fd28a3f17e..4d20e36daa4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2536,7 +2536,7 @@ switchbot-api==1.2.1 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.9.4 +systembridgeconnector==3.9.5 # homeassistant.components.tailscale tailscale==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bb9808357b..5ed43a93334 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1890,7 +1890,7 @@ surepy==0.8.0 switchbot-api==1.2.1 # homeassistant.components.system_bridge -systembridgeconnector==3.9.4 +systembridgeconnector==3.9.5 # homeassistant.components.tailscale tailscale==0.6.0 From e08d2408c910dc76907a18e627380cc2e0bd973f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Nov 2023 20:02:02 +0000 Subject: [PATCH 267/982] Bump accuweather to version 2.0.1 (#103532) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 5a5a1de2a01..307d68c4b7b 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==2.0.0"] + "requirements": ["accuweather==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4d20e36daa4..514c3373e96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,7 +147,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.0.0 +accuweather==2.0.1 # homeassistant.components.adax adax==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ed43a93334..882448097df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ Tami4EdgeAPI==2.1 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.0.0 +accuweather==2.0.1 # homeassistant.components.adax adax==0.3.0 From 054089291fe352b88a8732fbe64af8965b6bc18e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 6 Nov 2023 21:03:48 +0100 Subject: [PATCH 268/982] Bump nettigo-air-monitor to 2.2.1 (#103529) --- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index be571460b4a..8d4396d5d80 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==2.2.0"], + "requirements": ["nettigo-air-monitor==2.2.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 514c3373e96..27292aa3514 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1284,7 +1284,7 @@ netdata==1.1.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.2.0 +nettigo-air-monitor==2.2.1 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 882448097df..ba4283672c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1004,7 +1004,7 @@ nessclient==1.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.2.0 +nettigo-air-monitor==2.2.1 # homeassistant.components.nexia nexia==2.0.7 From ae516ffbb570aeca27c75aacc0f6dcd72d98efc7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 6 Nov 2023 14:26:00 -0600 Subject: [PATCH 269/982] Automatically convert TTS audio to MP3 on demand (#102814) * Add ATTR_PREFERRED_FORMAT to TTS for auto-converting audio * Move conversion into SpeechManager * Handle None case for expected_extension * Only use ATTR_AUDIO_OUTPUT * Prefer MP3 in pipelines * Automatically convert to mp3 on demand * Add preferred audio format * Break out preferred format * Add ATTR_BLOCKING to allow async fetching * Make a copy of supported options * Fix MaryTTS tests * Update ESPHome to use "wav" instead of "raw" * Clean up tests, remove blocking * Clean up rest of TTS tests * Fix ESPHome tests * More test coverage --- .../components/assist_pipeline/pipeline.py | 8 +- homeassistant/components/cloud/tts.py | 2 +- .../components/esphome/voice_assistant.py | 31 +++- homeassistant/components/tts/__init__.py | 148 +++++++++++++++--- homeassistant/components/tts/manifest.json | 2 +- homeassistant/components/wyoming/tts.py | 35 +---- homeassistant/package_constraints.txt | 1 + tests/components/assist_pipeline/test_init.py | 41 ++++- .../esphome/test_voice_assistant.py | 70 ++++++++- tests/components/google_translate/test_tts.py | 60 ++++--- tests/components/marytts/test_tts.py | 61 +++++--- tests/components/microsoft/test_tts.py | 78 ++++++--- tests/components/tts/common.py | 16 ++ tests/components/tts/test_init.py | 53 ++++--- tests/components/tts/test_media_source.py | 40 +++-- tests/components/voicerss/test_tts.py | 72 +++++---- .../wyoming/snapshots/test_tts.ambr | 33 ++++ tests/components/wyoming/test_tts.py | 103 ++++++++---- tests/components/yandextts/test_tts.py | 110 +++++++++---- 19 files changed, 723 insertions(+), 241 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1e1c0b6f495..c6d0f6c5435 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -971,12 +971,16 @@ class PipelineRun: # pipeline.tts_engine can't be None or this function is not called engine = cast(str, self.pipeline.tts_engine) - tts_options = {} + tts_options: dict[str, Any] = {} if self.pipeline.tts_voice is not None: tts_options[tts.ATTR_VOICE] = self.pipeline.tts_voice if self.tts_audio_output is not None: - tts_options[tts.ATTR_AUDIO_OUTPUT] = self.tts_audio_output + tts_options[tts.ATTR_PREFERRED_FORMAT] = self.tts_audio_output + if self.tts_audio_output == "wav": + # 16 Khz, 16-bit mono + tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = 16000 + tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = 1 try: options_supported = await tts.async_support_options( diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 88f24d1290f..f8152243bf5 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -150,4 +150,4 @@ class CloudProvider(Provider): _LOGGER.error("Voice error: %s", err) return (None, None) - return (str(options[ATTR_AUDIO_OUTPUT]), data) + return (str(options[ATTR_AUDIO_OUTPUT].value), data) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 26c0780d735..bb62d495076 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterable, Callable +import io import logging import socket from typing import cast +import wave from aioesphomeapi import ( VoiceAssistantAudioSettings, @@ -88,6 +90,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.handle_event = handle_event self.handle_finished = handle_finished self._tts_done = asyncio.Event() + self._tts_task: asyncio.Task | None = None async def start_server(self) -> int: """Start accepting connections.""" @@ -189,7 +192,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): if self.device_info.voice_assistant_version >= 2: media_id = event.data["tts_output"]["media_id"] - self.hass.async_create_background_task( + self._tts_task = self.hass.async_create_background_task( self._send_tts(media_id), "esphome_voice_assistant_tts" ) else: @@ -228,7 +231,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): audio_settings = VoiceAssistantAudioSettings() tts_audio_output = ( - "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" + "wav" if self.device_info.voice_assistant_version >= 2 else "mp3" ) _LOGGER.debug("Starting pipeline") @@ -302,11 +305,32 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} ) - _extension, audio_bytes = await tts.async_get_media_source_audio( + extension, data = await tts.async_get_media_source_audio( self.hass, media_id, ) + if extension != "wav": + raise ValueError(f"Only WAV audio can be streamed, got {extension}") + + with io.BytesIO(data) as wav_io: + with wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + + if ( + (sample_rate != 16000) + or (sample_width != 2) + or (sample_channels != 1) + ): + raise ValueError( + "Expected rate/width/channels as 16000/2/1," + " got {sample_rate}/{sample_width}/{sample_channels}}" + ) + + audio_bytes = wav_file.readframes(wav_file.getnframes()) + _LOGGER.debug("Sending %d bytes of audio", len(audio_bytes)) bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8 @@ -330,4 +354,5 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, {} ) + self._tts_task = None self._tts_done.set() diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 4402722e37f..f84c819e739 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -13,6 +13,8 @@ import logging import mimetypes import os import re +import subprocess +import tempfile from typing import Any, TypedDict, final from aiohttp import web @@ -20,7 +22,7 @@ import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, @@ -72,11 +74,15 @@ __all__ = [ "async_get_media_source_audio", "async_support_options", "ATTR_AUDIO_OUTPUT", + "ATTR_PREFERRED_FORMAT", + "ATTR_PREFERRED_SAMPLE_RATE", + "ATTR_PREFERRED_SAMPLE_CHANNELS", "CONF_LANG", "DEFAULT_CACHE_DIR", "generate_media_source_id", "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "SampleFormat", "Provider", "TtsAudioType", "Voice", @@ -86,6 +92,9 @@ _LOGGER = logging.getLogger(__name__) ATTR_PLATFORM = "platform" ATTR_AUDIO_OUTPUT = "audio_output" +ATTR_PREFERRED_FORMAT = "preferred_format" +ATTR_PREFERRED_SAMPLE_RATE = "preferred_sample_rate" +ATTR_PREFERRED_SAMPLE_CHANNELS = "preferred_sample_channels" ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" ATTR_VOICE = "voice" @@ -199,6 +208,83 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: return languages +async def async_convert_audio( + hass: HomeAssistant, + from_extension: str, + audio_bytes: bytes, + to_extension: str, + to_sample_rate: int | None = None, + to_sample_channels: int | None = None, +) -> bytes: + """Convert audio to a preferred format using ffmpeg.""" + ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) + return await hass.async_add_executor_job( + lambda: _convert_audio( + ffmpeg_manager.binary, + from_extension, + audio_bytes, + to_extension, + to_sample_rate=to_sample_rate, + to_sample_channels=to_sample_channels, + ) + ) + + +def _convert_audio( + ffmpeg_binary: str, + from_extension: str, + audio_bytes: bytes, + to_extension: str, + to_sample_rate: int | None = None, + to_sample_channels: int | None = None, +) -> bytes: + """Convert audio to a preferred format using ffmpeg.""" + + # We have to use a temporary file here because some formats like WAV store + # the length of the file in the header, and therefore cannot be written in a + # streaming fashion. + with tempfile.NamedTemporaryFile( + mode="wb+", suffix=f".{to_extension}" + ) as output_file: + # input + command = [ + ffmpeg_binary, + "-y", # overwrite temp file + "-f", + from_extension, + "-i", + "pipe:", # input from stdin + ] + + # output + command.extend(["-f", to_extension]) + + if to_sample_rate is not None: + command.extend(["-ar", str(to_sample_rate)]) + + if to_sample_channels is not None: + command.extend(["-ac", str(to_sample_channels)]) + + if to_extension == "mp3": + # Max quality for MP3 + command.extend(["-q:a", "0"]) + + command.append(output_file.name) + + with subprocess.Popen( + command, stdin=subprocess.PIPE, stderr=subprocess.PIPE + ) as proc: + _stdout, stderr = proc.communicate(input=audio_bytes) + if proc.returncode != 0: + _LOGGER.error(stderr.decode()) + raise RuntimeError( + f"Unexpected error while running ffmpeg with arguments: {command}. See log for details." + ) + + output_file.seek(0) + return output_file.read() + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up TTS.""" websocket_api.async_register_command(hass, websocket_list_engines) @@ -482,7 +568,18 @@ class SpeechManager: merged_options = dict(engine_instance.default_options or {}) merged_options.update(options or {}) - supported_options = engine_instance.supported_options or [] + supported_options = list(engine_instance.supported_options or []) + + # ATTR_PREFERRED_* options are always "supported" since they're used to + # convert audio after the TTS has run (if necessary). + supported_options.extend( + ( + ATTR_PREFERRED_FORMAT, + ATTR_PREFERRED_SAMPLE_RATE, + ATTR_PREFERRED_SAMPLE_CHANNELS, + ) + ) + invalid_opts = [ opt_name for opt_name in merged_options if opt_name not in supported_options ] @@ -520,12 +617,7 @@ class SpeechManager: # Load speech from engine into memory else: filename = await self._async_get_tts_audio( - engine_instance, - cache_key, - message, - use_cache, - language, - options, + engine_instance, cache_key, message, use_cache, language, options ) return f"/api/tts_proxy/{filename}" @@ -590,10 +682,10 @@ class SpeechManager: This method is a coroutine. """ - if options is not None and ATTR_AUDIO_OUTPUT in options: - expected_extension = options[ATTR_AUDIO_OUTPUT] - else: - expected_extension = None + options = options or {} + + # Default to MP3 unless a different format is preferred + final_extension = options.get(ATTR_PREFERRED_FORMAT, "mp3") async def get_tts_data() -> str: """Handle data available.""" @@ -614,8 +706,27 @@ class SpeechManager: f"No TTS from {engine_instance.name} for '{message}'" ) + # Only convert if we have a preferred format different than the + # expected format from the TTS system, or if a specific sample + # rate/format/channel count is requested. + needs_conversion = ( + (final_extension != extension) + or (ATTR_PREFERRED_SAMPLE_RATE in options) + or (ATTR_PREFERRED_SAMPLE_CHANNELS in options) + ) + + if needs_conversion: + data = await async_convert_audio( + self.hass, + extension, + data, + to_extension=final_extension, + to_sample_rate=options.get(ATTR_PREFERRED_SAMPLE_RATE), + to_sample_channels=options.get(ATTR_PREFERRED_SAMPLE_CHANNELS), + ) + # Create file infos - filename = f"{cache_key}.{extension}".lower() + filename = f"{cache_key}.{final_extension}".lower() # Validate filename if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( @@ -626,10 +737,11 @@ class SpeechManager: ) # Save to memory - if extension == "mp3": + if final_extension == "mp3": data = self.write_tags( filename, data, engine_instance.name, message, language, options ) + self._async_store_to_memcache(cache_key, filename, data) if cache: @@ -641,9 +753,6 @@ class SpeechManager: audio_task = self.hass.async_create_task(get_tts_data()) - if expected_extension is None: - return await audio_task - def handle_error(_future: asyncio.Future) -> None: """Handle error.""" if audio_task.exception(): @@ -651,7 +760,7 @@ class SpeechManager: audio_task.add_done_callback(handle_error) - filename = f"{cache_key}.{expected_extension}".lower() + filename = f"{cache_key}.{final_extension}".lower() self.mem_cache[cache_key] = { "filename": filename, "voice": b"", @@ -747,11 +856,12 @@ class SpeechManager: raise HomeAssistantError(f"{cache_key} not in cache!") await self._async_file_to_mem(cache_key) - content, _ = mimetypes.guess_type(filename) cached = self.mem_cache[cache_key] if pending := cached.get("pending"): await pending cached = self.mem_cache[cache_key] + + content, _ = mimetypes.guess_type(filename) return content, cached["voice"] @staticmethod diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index f1120ed2750..338a8c35003 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -3,7 +3,7 @@ "name": "Text-to-speech (TTS)", "after_dependencies": ["media_player"], "codeowners": ["@home-assistant/core", "@pvizeli"], - "dependencies": ["http"], + "dependencies": ["http", "ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/tts", "integration_type": "entity", "loggers": ["mutagen"], diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 6510fd8c761..cde771cd330 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -4,7 +4,7 @@ import io import logging import wave -from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStop +from wyoming.audio import AudioChunk, AudioStop from wyoming.client import AsyncTcpClient from wyoming.tts import Synthesize, SynthesizeVoice @@ -88,12 +88,16 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): @property def supported_options(self): """Return list of supported options like voice, emotion.""" - return [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE, ATTR_SPEAKER] + return [ + tts.ATTR_AUDIO_OUTPUT, + tts.ATTR_VOICE, + ATTR_SPEAKER, + ] @property def default_options(self): """Return a dict include default options.""" - return {tts.ATTR_AUDIO_OUTPUT: "wav"} + return {} @callback def async_get_supported_voices(self, language: str) -> list[tts.Voice] | None: @@ -143,27 +147,4 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): except (OSError, WyomingError): return (None, None) - if options[tts.ATTR_AUDIO_OUTPUT] == "wav": - return ("wav", data) - - # Raw output (convert to 16Khz, 16-bit mono) - with io.BytesIO(data) as wav_io: - wav_reader: wave.Wave_read = wave.open(wav_io, "rb") - raw_data = ( - AudioChunkConverter( - rate=16000, - width=2, - channels=1, - ) - .convert( - AudioChunk( - audio=wav_reader.readframes(wav_reader.getnframes()), - rate=wav_reader.getframerate(), - width=wav_reader.getsampwidth(), - channels=wav_reader.getnchannels(), - ) - ) - .audio - ) - - return ("raw", raw_data) + return ("wav", data) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2c38dc8f153..fac2abb7df1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,6 +20,7 @@ cryptography==41.0.4 dbus-fast==2.12.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 +ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index a98858a1bce..24a4a92536d 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -9,7 +9,7 @@ import wave import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import assist_pipeline, stt +from homeassistant.components import assist_pipeline, stt, tts from homeassistant.components.assist_pipeline.const import ( CONF_DEBUG_RECORDING_DIR, DOMAIN, @@ -660,3 +660,42 @@ def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: assert run_1 == run_1 assert run_1 != run_2 assert run_1 != 1234 + + +async def test_tts_audio_output( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + snapshot: SnapshotAssertion, +) -> None: + """Test using tts_audio_output with wav sets options correctly.""" + + def event_callback(event): + pass + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + conversation_id=None, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + tts_audio_output="wav", + ), + ) + await pipeline_input.validate() + + # Verify TTS audio settings + assert pipeline_input.run.tts_options is not None + assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) == 16000 + assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) == 1 diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index 9b6bcf1c6c7..ca74c99f0cd 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -1,8 +1,10 @@ """Test ESPHome voice assistant server.""" import asyncio +import io import socket from unittest.mock import Mock, patch +import wave from aioesphomeapi import VoiceAssistantEventType import pytest @@ -340,9 +342,18 @@ async def test_send_tts( voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: """Test the UDP server calls sendto to transmit audio data to device.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(_ONE_SECOND)) + + wav_bytes = wav_io.getvalue() + with patch( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("raw", bytes(1024)), + return_value=("wav", wav_bytes), ): voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) @@ -360,6 +371,63 @@ async def test_send_tts( voice_assistant_udp_server_v2.transport.sendto.assert_called() +async def test_send_tts_wrong_sample_rate( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server calls sendto to transmit audio data to device.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) # should be 16000 + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(_ONE_SECOND)) + + wav_bytes = wav_io.getvalue() + + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("wav", wav_bytes), + ), pytest.raises(ValueError): + voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + assert voice_assistant_udp_server_v2._tts_task is not None + await voice_assistant_udp_server_v2._tts_task # raises ValueError + + +async def test_send_tts_wrong_format( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test that only WAV audio will be streamed.""" + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("raw", bytes(1024)), + ), pytest.raises(ValueError): + voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + assert voice_assistant_udp_server_v2._tts_task is not None + await voice_assistant_udp_server_v2._tts_task # raises ValueError + + async def test_wake_word( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index d6669ee3c5f..fd1ddd8a4f2 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -2,13 +2,14 @@ from __future__ import annotations from collections.abc import Generator +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch from gtts import gTTSError import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.google_translate.const import CONF_TLD, DOMAIN from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, @@ -18,10 +19,11 @@ from homeassistant.components.media_player import ( from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_mock_service +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) @@ -35,15 +37,6 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir): return mock_tts_cache_dir -async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> str: - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url - - @pytest.fixture async def calls(hass: HomeAssistant) -> list[ServiceCall]: """Mock media player calls.""" @@ -128,6 +121,7 @@ async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) - async def test_tts_service( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -142,9 +136,11 @@ async def test_tts_service( ) assert len(calls) == 1 - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 - assert url.endswith(".mp3") assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -180,6 +176,7 @@ async def test_tts_service( async def test_service_say_german_config( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -194,7 +191,10 @@ async def test_service_say_german_config( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -231,6 +231,7 @@ async def test_service_say_german_config( async def test_service_say_german_service( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -245,7 +246,10 @@ async def test_service_say_german_service( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -281,6 +285,7 @@ async def test_service_say_german_service( async def test_service_say_en_uk_config( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -295,7 +300,10 @@ async def test_service_say_en_uk_config( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -332,6 +340,7 @@ async def test_service_say_en_uk_config( async def test_service_say_en_uk_service( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -346,7 +355,10 @@ async def test_service_say_en_uk_service( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -383,6 +395,7 @@ async def test_service_say_en_uk_service( async def test_service_say_en_couk( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -397,9 +410,11 @@ async def test_service_say_en_couk( ) assert len(calls) == 1 - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 - assert url.endswith(".mp3") assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -434,6 +449,7 @@ async def test_service_say_en_couk( async def test_service_say_error( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -450,6 +466,8 @@ async def test_service_say_error( ) assert len(calls) == 1 - with pytest.raises(HomeAssistantError): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) assert len(mock_gtts.mock_calls) == 2 diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 4282b86ec2e..474d2f19faf 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -1,9 +1,12 @@ """The tests for the MaryTTS speech platform.""" +from http import HTTPStatus +import io from unittest.mock import patch +import wave import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -13,15 +16,19 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator -async def get_media_source_url(hass, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) +def get_empty_wav() -> bytes: + """Get bytes for empty WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url + return wav_io.getvalue() @pytest.fixture(autouse=True) @@ -39,7 +46,9 @@ async def test_setup_component(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_service_say(hass: HomeAssistant) -> None: +async def test_service_say( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -51,7 +60,7 @@ async def test_service_say(hass: HomeAssistant) -> None: with patch( "homeassistant.components.marytts.tts.MaryTTS.speak", - return_value=b"audio", + return_value=get_empty_wav(), ) as mock_speak: await hass.services.async_call( tts.DOMAIN, @@ -63,16 +72,22 @@ async def test_service_say(hass: HomeAssistant) -> None: blocking=True, ) - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media( + hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.OK + ) mock_speak.assert_called_once() mock_speak.assert_called_with("HomeAssistant", {}) assert len(calls) == 1 - assert url.endswith(".wav") -async def test_service_say_with_effect(hass: HomeAssistant) -> None: +async def test_service_say_with_effect( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: """Test service call say with effects.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -84,7 +99,7 @@ async def test_service_say_with_effect(hass: HomeAssistant) -> None: with patch( "homeassistant.components.marytts.tts.MaryTTS.speak", - return_value=b"audio", + return_value=get_empty_wav(), ) as mock_speak: await hass.services.async_call( tts.DOMAIN, @@ -96,16 +111,22 @@ async def test_service_say_with_effect(hass: HomeAssistant) -> None: blocking=True, ) - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media( + hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.OK + ) mock_speak.assert_called_once() mock_speak.assert_called_with("HomeAssistant", {"Volume": "amount:2.0;"}) assert len(calls) == 1 - assert url.endswith(".wav") -async def test_service_say_http_error(hass: HomeAssistant) -> None: +async def test_service_say_http_error( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -129,7 +150,11 @@ async def test_service_say_http_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - with pytest.raises(Exception): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media( + hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.NOT_FOUND + ) mock_speak.assert_called_once() diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index 9684d1aa7d5..bc6a3ac7dd7 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -1,10 +1,11 @@ """Tests for Microsoft text-to-speech.""" +from http import HTTPStatus from unittest.mock import patch from pycsspeechtts import pycsspeechtts import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -13,19 +14,12 @@ from homeassistant.components.media_player import ( from homeassistant.components.microsoft.tts import SUPPORTED_LANGUAGES from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceNotFound +from homeassistant.exceptions import ServiceNotFound from homeassistant.setup import async_setup_component from tests.common import async_mock_service - - -async def get_media_source_url(hass: HomeAssistant, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) @@ -58,7 +52,9 @@ def mock_tts(): yield mock_tts -async def test_service_say(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say.""" await async_setup_component( @@ -76,9 +72,12 @@ async def test_service_say(hass: HomeAssistant, mock_tts, calls) -> None: ) assert len(calls) == 1 - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 - assert url.endswith(".mp3") assert mock_tts.mock_calls[1][2] == { "language": "en-us", @@ -93,7 +92,9 @@ async def test_service_say(hass: HomeAssistant, mock_tts, calls) -> None: } -async def test_service_say_en_gb_config(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_en_gb_config( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with en-gb code in the config.""" await async_setup_component( @@ -120,7 +121,11 @@ async def test_service_say_en_gb_config(hass: HomeAssistant, mock_tts, calls) -> ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 assert mock_tts.mock_calls[1][2] == { "language": "en-gb", @@ -135,7 +140,9 @@ async def test_service_say_en_gb_config(hass: HomeAssistant, mock_tts, calls) -> } -async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_en_gb_service( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with en-gb code in the service.""" await async_setup_component( @@ -157,7 +164,11 @@ async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) - ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 assert mock_tts.mock_calls[1][2] == { "language": "en-gb", @@ -172,7 +183,9 @@ async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) - } -async def test_service_say_fa_ir_config(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_fa_ir_config( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with fa-ir code in the config.""" await async_setup_component( @@ -199,7 +212,11 @@ async def test_service_say_fa_ir_config(hass: HomeAssistant, mock_tts, calls) -> ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 assert mock_tts.mock_calls[1][2] == { "language": "fa-ir", @@ -214,7 +231,9 @@ async def test_service_say_fa_ir_config(hass: HomeAssistant, mock_tts, calls) -> } -async def test_service_say_fa_ir_service(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_fa_ir_service( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with fa-ir code in the service.""" config = { @@ -240,7 +259,11 @@ async def test_service_say_fa_ir_service(hass: HomeAssistant, mock_tts, calls) - ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 assert mock_tts.mock_calls[1][2] == { "language": "fa-ir", @@ -295,7 +318,9 @@ async def test_invalid_language(hass: HomeAssistant, mock_tts, calls) -> None: assert len(mock_tts.mock_calls) == 0 -async def test_service_say_error(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_error( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with http error.""" mock_tts.return_value.speak.side_effect = pycsspeechtts.requests.HTTPError await async_setup_component( @@ -313,6 +338,9 @@ async def test_service_say_error(hass: HomeAssistant, mock_tts, calls) -> None: ) assert len(calls) == 1 - with pytest.raises(HomeAssistantError): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) + assert len(mock_tts.mock_calls) == 2 diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index a9a95eae2f4..0c3642df6fe 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +33,7 @@ from tests.common import ( mock_integration, mock_platform, ) +from tests.typing import ClientSessionGenerator DEFAULT_LANG = "en_US" SUPPORT_LANGUAGES = ["de_CH", "de_DE", "en_GB", "en_US"] @@ -103,6 +105,20 @@ async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> st return resolved.url +async def retrieve_media( + hass: HomeAssistant, hass_client: ClientSessionGenerator, media_content_id: str +) -> HTTPStatus: + """Get the media source url.""" + url = await get_media_source_url(hass, media_content_id) + + # Ensure media has been generated by requesting it + await hass.async_block_till_done() + client = await hass_client() + req = await client.get(url) + + return req.status + + class BaseProvider: """Test speech API provider.""" diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 2656beba236..71be6b3bb11 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components import tts +from homeassistant.components import ffmpeg, tts from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, @@ -15,7 +15,6 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) -from homeassistant.components.media_source import Unresolvable from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -33,6 +32,7 @@ from .common import ( get_media_source_url, mock_config_entry_setup, mock_setup, + retrieve_media, ) from tests.common import async_mock_service, mock_restore_cache @@ -75,7 +75,9 @@ async def test_default_entity_attributes() -> None: async def test_config_entry_unload( - hass: HomeAssistant, mock_tts_entity: MockTTSEntity + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MockTTSEntity, ) -> None: """Test we can unload config entry.""" entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" @@ -104,7 +106,12 @@ async def test_config_entry_unload( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media( + hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.OK + ) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -1159,6 +1166,7 @@ class MockEntityEmpty(MockTTSEntity): ) async def test_service_get_tts_error( hass: HomeAssistant, + hass_client: ClientSessionGenerator, setup: str, tts_service: str, service_data: dict[str, Any], @@ -1173,8 +1181,10 @@ async def test_service_get_tts_error( blocking=True, ) assert len(calls) == 1 - with pytest.raises(Unresolvable): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) async def test_load_cache_legacy_retrieve_without_mem_cache( @@ -1454,7 +1464,11 @@ async def test_legacy_fetching_in_async( # Test async_get_media_source_audio media_source_id = tts.generate_media_source_id( - hass, "test message", "test", "en_US", None, None + hass, + "test message", + "test", + "en_US", + cache=None, ) task = hass.async_create_task( @@ -1508,16 +1522,6 @@ async def test_fetching_in_async( class EntityWithAsyncFetching(MockTTSEntity): """Entity that supports audio output option.""" - @property - def supported_options(self) -> list[str]: - """Return list of supported options like voice, emotions.""" - return [tts.ATTR_AUDIO_OUTPUT] - - @property - def default_options(self) -> dict[str, str]: - """Return a dict including the default options.""" - return {tts.ATTR_AUDIO_OUTPUT: "mp3"} - async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: @@ -1527,7 +1531,11 @@ async def test_fetching_in_async( # Test async_get_media_source_audio media_source_id = tts.generate_media_source_id( - hass, "test message", "tts.test", "en_US", None, None + hass, + "test message", + "tts.test", + "en_US", + cache=None, ) task = hass.async_create_task( @@ -1751,3 +1759,12 @@ async def test_ws_list_voices( {"voice_id": "fran_drescher", "name": "Fran Drescher"}, ] } + + +async def test_async_convert_audio_error(hass: HomeAssistant) -> None: + """Test that ffmpeg failing during audio conversion will raise an error.""" + assert await async_setup_component(hass, ffmpeg.DOMAIN, {}) + + with pytest.raises(RuntimeError): + # Simulate a bad WAV file + await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index 86f1a3bcf3e..641c02064ec 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -1,4 +1,5 @@ """Tests for TTS media source.""" +from http import HTTPStatus from unittest.mock import MagicMock import pytest @@ -14,8 +15,11 @@ from .common import ( MockTTSEntity, mock_config_entry_setup, mock_setup, + retrieve_media, ) +from tests.typing import ClientSessionGenerator + class MSEntity(MockTTSEntity): """Test speech API entity.""" @@ -88,16 +92,18 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None: @pytest.mark.parametrize("mock_provider", [MSProvider(DEFAULT_LANG)]) -async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) -> None: +async def test_legacy_resolving( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_provider: MSProvider +) -> None: """Test resolving legacy provider.""" await mock_setup(hass, mock_provider) mock_get_tts_audio = mock_provider.get_tts_audio - media = await media_source.async_resolve_media( - hass, "media-source://tts/test?message=Hello%20World", None - ) + media_id = "media-source://tts/test?message=Hello%20World" + media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" + assert await retrieve_media(hass, hass_client, media_id) == HTTPStatus.OK assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] @@ -107,13 +113,11 @@ async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) # Pass language and options mock_get_tts_audio.reset_mock() - media = await media_source.async_resolve_media( - hass, - "media-source://tts/test?message=Bye%20World&language=de_DE&voice=Paulus", - None, - ) + media_id = "media-source://tts/test?message=Bye%20World&language=de_DE&voice=Paulus" + media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" + assert await retrieve_media(hass, hass_client, media_id) == HTTPStatus.OK assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] @@ -123,16 +127,18 @@ async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) @pytest.mark.parametrize("mock_tts_entity", [MSEntity(DEFAULT_LANG)]) -async def test_resolving(hass: HomeAssistant, mock_tts_entity: MSEntity) -> None: +async def test_resolving( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts_entity: MSEntity +) -> None: """Test resolving entity.""" await mock_config_entry_setup(hass, mock_tts_entity) mock_get_tts_audio = mock_tts_entity.get_tts_audio - media = await media_source.async_resolve_media( - hass, "media-source://tts/tts.test?message=Hello%20World", None - ) + media_id = "media-source://tts/tts.test?message=Hello%20World" + media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" + assert await retrieve_media(hass, hass_client, media_id) == HTTPStatus.OK assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] @@ -142,13 +148,13 @@ async def test_resolving(hass: HomeAssistant, mock_tts_entity: MSEntity) -> None # Pass language and options mock_get_tts_audio.reset_mock() - media = await media_source.async_resolve_media( - hass, - "media-source://tts/tts.test?message=Bye%20World&language=de_DE&voice=Paulus", - None, + media_id = ( + "media-source://tts/tts.test?message=Bye%20World&language=de_DE&voice=Paulus" ) + media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" + assert await retrieve_media(hass, hass_client, media_id) == HTTPStatus.OK assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 57a5b298162..24997c9d459 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -4,18 +4,19 @@ from http import HTTPStatus import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.components.tts.common import retrieve_media from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator URL = "https://api.voicerss.org/" FORM_DATA = { @@ -38,15 +39,6 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir): return mock_tts_cache_dir -async def get_media_source_url(hass, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url - - async def test_setup_component(hass: HomeAssistant) -> None: """Test setup component.""" config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} @@ -66,7 +58,9 @@ async def test_setup_component_without_api_key(hass: HomeAssistant) -> None: async def test_service_say( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -90,14 +84,18 @@ async def test_service_say( await hass.async_block_till_done() assert len(calls) == 1 - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - assert url.endswith(".mp3") + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA async def test_service_say_german_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with german code in the config.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -128,13 +126,18 @@ async def test_service_say_german_config( await hass.async_block_till_done() assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == form_data async def test_service_say_german_service( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with german code in the service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -160,13 +163,18 @@ async def test_service_say_german_service( await hass.async_block_till_done() assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == form_data async def test_service_say_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with http response 400.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -189,14 +197,18 @@ async def test_service_say_error( ) await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA async def test_service_say_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with http timeout.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -219,14 +231,18 @@ async def test_service_say_timeout( ) await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA async def test_service_say_error_msg( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with http error api message.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -254,7 +270,9 @@ async def test_service_say_error_msg( ) await hass.async_block_till_done() - with pytest.raises(media_source.Unresolvable): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index 1cb5a6cb874..299bddb07e5 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -10,6 +10,39 @@ }), ]) # --- +# name: test_get_tts_audio_different_formats + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- +# name: test_get_tts_audio_different_formats.1 + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- +# name: test_get_tts_audio_mp3 + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- # name: test_get_tts_audio_raw list([ dict({ diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 51a684bc4fd..68b7b2b62bc 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -51,31 +51,7 @@ async def test_get_tts_audio(hass: HomeAssistant, init_wyoming_tts, snapshot) -> AudioStop().event(), ] - with patch( - "homeassistant.components.wyoming.tts.AsyncTcpClient", - MockAsyncTcpClient(audio_events), - ) as mock_client: - extension, data = await tts.async_get_media_source_audio( - hass, - tts.generate_media_source_id(hass, "Hello world", "tts.test_tts", "en-US"), - ) - - assert extension == "wav" - assert data is not None - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - assert wav_file.getframerate() == 16000 - assert wav_file.getsampwidth() == 2 - assert wav_file.getnchannels() == 1 - assert wav_file.readframes(wav_file.getnframes()) == audio - - assert mock_client.written == snapshot - - -async def test_get_tts_audio_raw( - hass: HomeAssistant, init_wyoming_tts, snapshot -) -> None: - """Test get raw audio.""" - audio = bytes(100) + # Verify audio audio_events = [ AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), @@ -92,12 +68,83 @@ async def test_get_tts_audio_raw( "Hello world", "tts.test_tts", "en-US", - options={tts.ATTR_AUDIO_OUTPUT: "raw"}, + options={tts.ATTR_PREFERRED_FORMAT: "wav"}, ), ) - assert extension == "raw" - assert data == audio + assert extension == "wav" + assert data is not None + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + assert wav_file.getframerate() == 16000 + assert wav_file.getsampwidth() == 2 + assert wav_file.getnchannels() == 1 + assert wav_file.readframes(wav_file.getnframes()) == audio + + assert mock_client.written == snapshot + + +async def test_get_tts_audio_different_formats( + hass: HomeAssistant, init_wyoming_tts, snapshot +) -> None: + """Test changing preferred audio format.""" + audio = bytes(16000 * 2 * 1) # one second + audio_events = [ + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + ] + + # Request a different sample rate, etc. + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + extension, data = await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, + "Hello world", + "tts.test_tts", + "en-US", + options={ + tts.ATTR_PREFERRED_FORMAT: "wav", + tts.ATTR_PREFERRED_SAMPLE_RATE: 48000, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, + }, + ), + ) + + assert extension == "wav" + assert data is not None + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + assert wav_file.getframerate() == 48000 + assert wav_file.getsampwidth() == 2 + assert wav_file.getnchannels() == 2 + assert wav_file.getnframes() == wav_file.getframerate() # one second + + assert mock_client.written == snapshot + + # MP3 is the default + audio_events = [ + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + ] + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + extension, data = await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, + "Hello world", + "tts.test_tts", + "en-US", + ), + ) + + assert extension == "mp3" + assert b"ID3" in data assert mock_client.written == snapshot diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index d04aef6b16b..a8052e45047 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -4,7 +4,7 @@ from http import HTTPStatus import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -14,7 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.components.tts.common import retrieve_media from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator URL = "https://tts.voicetech.yandex.net/generate?" @@ -30,15 +32,6 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir): return mock_tts_cache_dir -async def get_media_source_url(hass, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url - - async def test_setup_component(hass: HomeAssistant) -> None: """Test setup component.""" config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} @@ -58,7 +51,9 @@ async def test_setup_component_without_api_key(hass: HomeAssistant) -> None: async def test_service_say( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -87,12 +82,18 @@ async def test_service_say( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_russian_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -128,12 +129,18 @@ async def test_service_say_russian_config( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_russian_service( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -166,12 +173,18 @@ async def test_service_say_russian_service( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -207,13 +220,18 @@ async def test_service_say_timeout( await hass.async_block_till_done() assert len(calls) == 1 - with pytest.raises(media_source.Unresolvable): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_http_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -248,12 +266,16 @@ async def test_service_say_http_error( ) assert len(calls) == 1 - with pytest.raises(media_source.Unresolvable): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) async def test_service_say_specified_speaker( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -288,12 +310,18 @@ async def test_service_say_specified_speaker( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_specified_emotion( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -328,13 +356,18 @@ async def test_service_say_specified_emotion( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_specified_low_speed( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -365,13 +398,18 @@ async def test_service_say_specified_low_speed( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_specified_speed( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -400,13 +438,18 @@ async def test_service_say_specified_speed( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_specified_options( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say with options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -438,6 +481,9 @@ async def test_service_say_specified_options( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 From 0dc6c1d03a7c7ced3a76daeb92bf8cb369c1f186 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 6 Nov 2023 21:43:02 +0100 Subject: [PATCH 270/982] Fix entry data typing in bsblan (#103544) --- homeassistant/components/bsblan/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 224cb479dda..def2cfaf56a 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -1,7 +1,7 @@ """The BSB-Lan integration.""" import dataclasses -from bsblan import BSBLAN, Device, Info, State, StaticState +from bsblan import BSBLAN, Device, Info, StaticState from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -13,7 +13,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_PASSKEY, DOMAIN from .coordinator import BSBLanUpdateCoordinator @@ -25,7 +24,7 @@ PLATFORMS = [Platform.CLIMATE] class HomeAssistantBSBLANData: """BSBLan data stored in the Home Assistant data object.""" - coordinator: DataUpdateCoordinator[State] + coordinator: BSBLanUpdateCoordinator client: BSBLAN device: Device info: Info From af4ccefb8ab543077d2562b1ea304b3da82f6d3d Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 6 Nov 2023 22:13:17 +0100 Subject: [PATCH 271/982] Bump evohome-async to 0.4.6 (#103534) * bump client to 0.4.5 * bump to 0.4.6 * adress lint mypy fails --- homeassistant/components/evohome/__init__.py | 9 ++++++--- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index b47e86dd501..c26310bf61c 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -427,7 +427,9 @@ class EvoBroker: async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" # evohomeasync2 uses naive/local datetimes - access_token_expires = _dt_local_to_aware(self.client.access_token_expires) + access_token_expires = _dt_local_to_aware( + self.client.access_token_expires # type: ignore[arg-type] + ) app_storage = { CONF_USERNAME: self.client.username, @@ -437,8 +439,9 @@ class EvoBroker: } if self.client_v1 and self.client_v1.user_data: - app_storage[USER_DATA] = { - "userInfo": {"userID": self.client_v1.user_data["userInfo"]["userID"]}, + user_id = self.client_v1.user_data["userInfo"]["userID"] # type: ignore[index] + app_storage[USER_DATA] = { # type: ignore[assignment] + "userInfo": {"userID": user_id}, "sessionId": self.client_v1.user_data["sessionId"], } else: diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 3cf07dfdfc4..58efb2c25b2 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.4"] + "requirements": ["evohome-async==0.4.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 27292aa3514..8e9f5ff1084 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -785,7 +785,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.4 +evohome-async==0.4.6 # homeassistant.components.faa_delays faadelays==2023.9.1 From 0b4d20b1f9b66eb2cec843528cc60df87f2e1bdd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Nov 2023 23:31:54 +0100 Subject: [PATCH 272/982] Update vehicle to 2.2.0 (#103545) --- homeassistant/components/rdw/diagnostics.py | 4 +- homeassistant/components/rdw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/rdw/conftest.py | 4 +- .../rdw/snapshots/test_diagnostics.ambr | 50 +++++++++---------- 6 files changed, 31 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/rdw/diagnostics.py b/homeassistant/components/rdw/diagnostics.py index 13c762f695f..dbf3d8e21c0 100644 --- a/homeassistant/components/rdw/diagnostics.py +++ b/homeassistant/components/rdw/diagnostics.py @@ -1,7 +1,6 @@ """Diagnostics support for RDW.""" from __future__ import annotations -import json from typing import Any from vehicle import Vehicle @@ -18,6 +17,5 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: DataUpdateCoordinator[Vehicle] = hass.data[DOMAIN][entry.entry_id] - # Round-trip via JSON to trigger serialization - data: dict[str, Any] = json.loads(coordinator.data.json()) + data: dict[str, Any] = coordinator.data.to_dict() return data diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index bc8d3be8451..e63478976e3 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==2.0.0"] + "requirements": ["vehicle==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e9f5ff1084..994f2fc459b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2664,7 +2664,7 @@ uvcclient==0.11.0 vallox-websocket-api==4.0.2 # homeassistant.components.rdw -vehicle==2.0.0 +vehicle==2.2.0 # homeassistant.components.velbus velbus-aio==2023.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba4283672c3..914398e180b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ uvcclient==0.11.0 vallox-websocket-api==4.0.2 # homeassistant.components.rdw -vehicle==2.0.0 +vehicle==2.2.0 # homeassistant.components.velbus velbus-aio==2023.10.2 diff --git a/tests/components/rdw/conftest.py b/tests/components/rdw/conftest.py index 4be17f00264..5fe40b0b497 100644 --- a/tests/components/rdw/conftest.py +++ b/tests/components/rdw/conftest.py @@ -38,7 +38,7 @@ def mock_rdw_config_flow() -> Generator[None, MagicMock, None]: "homeassistant.components.rdw.config_flow.RDW", autospec=True ) as rdw_mock: rdw = rdw_mock.return_value - rdw.vehicle.return_value = Vehicle.parse_raw(load_fixture("rdw/11ZKZ3.json")) + rdw.vehicle.return_value = Vehicle.from_json(load_fixture("rdw/11ZKZ3.json")) yield rdw @@ -49,7 +49,7 @@ def mock_rdw(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None] if hasattr(request, "param") and request.param: fixture = request.param - vehicle = Vehicle.parse_raw(load_fixture(fixture)) + vehicle = Vehicle.from_json(load_fixture(fixture)) with patch("homeassistant.components.rdw.RDW", autospec=True) as rdw_mock: rdw = rdw_mock.return_value rdw.vehicle.return_value = vehicle diff --git a/tests/components/rdw/snapshots/test_diagnostics.ambr b/tests/components/rdw/snapshots/test_diagnostics.ambr index 6da03b67245..cc2a344025a 100644 --- a/tests/components/rdw/snapshots/test_diagnostics.ambr +++ b/tests/components/rdw/snapshots/test_diagnostics.ambr @@ -1,30 +1,30 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'apk_expiration': '2022-01-04', - 'ascription_date': '2021-11-04', - 'ascription_possible': True, - 'brand': 'Skoda', - 'energy_label': 'A', - 'engine_capacity': 999, - 'exported': False, - 'first_admission': '2013-01-04', - 'interior': 'hatchback', - 'last_odometer_registration_year': 2021, - 'liability_insured': False, - 'license_plate': '11ZKZ3', - 'list_price': 10697, - 'mass_driveable': 940, - 'mass_empty': 840, - 'model': 'Citigo', - 'number_of_cylinders': 3, - 'number_of_doors': 0, - 'number_of_seats': 4, - 'number_of_wheelchair_seats': 0, - 'number_of_wheels': 4, - 'odometer_judgement': 'Logisch', - 'pending_recall': False, - 'taxi': None, - 'vehicle_type': 'Personenauto', + 'aantal_cilinders': 3, + 'aantal_deuren': 0, + 'aantal_rolstoelplaatsen': 0, + 'aantal_wielen': 4, + 'aantal_zitplaatsen': 4, + 'catalogusprijs': 10697, + 'cilinderinhoud': 999, + 'datum_eerste_toelating': '20130104', + 'datum_tenaamstelling': '20211104', + 'export_indicator': 'Nee', + 'handelsbenaming': 'Citigo', + 'inrichting': 'hatchback', + 'jaar_laatste_registratie_tellerstand': 2021, + 'kenteken': '11ZKZ3', + 'massa_ledig_voertuig': 840, + 'massa_rijklaar': 940, + 'merk': 'Skoda', + 'openstaande_terugroepactie_indicator': 'Nee', + 'taxi_indicator': None, + 'tellerstandoordeel': 'Logisch', + 'tenaamstellen_mogelijk': 'Ja', + 'vervaldatum_apk': '20220104', + 'voertuigsoort': 'Personenauto', + 'wam_verzekerd': 'Nee', + 'zuinigheidslabel': 'A', }) # --- From c9e8a3a8870f8c7fc3a05bc482469825533f95d3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Nov 2023 23:43:56 +0100 Subject: [PATCH 273/982] Fix invalid MAC in samsungtv (#103512) * Fix invalid MAC in samsungtv * Also adjust __init__ --- .../components/samsungtv/__init__.py | 4 +- .../components/samsungtv/config_flow.py | 5 +- .../components/samsungtv/test_config_flow.py | 62 ++++++++++++++++--- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index b7d400ce831..2ced868ada7 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -211,7 +211,9 @@ async def _async_create_bridge_with_updated_data( partial(getmac.get_mac_address, ip=host) ) - if mac: + if mac and mac != "none": + # Samsung sometimes returns a value of "none" for the mac address + # this should be ignored LOGGER.info("Updated mac to %s for %s", mac, host) updated_data[CONF_MAC] = mac else: diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 124dab73004..f20a79cc9e6 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -219,7 +219,10 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._title = f"{self._name} ({self._model})" self._udn = _strip_uuid(dev_info.get("udn", info["id"])) if mac := mac_from_device_info(info): - self._mac = mac + # Samsung sometimes returns a value of "none" for the mac address + # this should be ignored - but also shouldn't trigger getmac + if mac != "none": + self._mac = mac elif mac := await self.hass.async_add_executor_job( partial(getmac.get_mac_address, ip=self._host) ): diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index a70a0042fcd..0eacd63b42d 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Samsung TV config flow.""" +from copy import deepcopy from ipaddress import ip_address from unittest.mock import ANY, AsyncMock, Mock, call, patch @@ -165,14 +166,6 @@ MOCK_DEVICE_INFO = { }, "id": "123", } -MOCK_DEVICE_INFO_2 = { - "device": { - "type": "Samsung SmartTV", - "name": "fake2_name", - "modelName": "fake2_model", - }, - "id": "345", -} AUTODETECT_LEGACY = { "name": "HomeAssistant", @@ -1968,3 +1961,56 @@ async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( assert result["step_id"] == "confirm" assert entry.data[CONF_MAC] == "aa:bb:ss:ss:dd:pp" assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" + + +@pytest.mark.usefixtures("remotews", "remoteencws_failing") +async def test_ssdp_update_mac(hass: HomeAssistant) -> None: + """Ensure that MAC address is correctly updated from SSDP.""" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", + return_value=MOCK_DEVICE_INFO, + ): + # entry was added + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + entry = result["result"] + assert entry.data[CONF_MANUFACTURER] == DEFAULT_MANUFACTURER + assert entry.data[CONF_MODEL] == "fake_model" + assert entry.data[CONF_MAC] is None + assert entry.unique_id == "123" + + device_info = deepcopy(MOCK_DEVICE_INFO) + device_info["device"]["wifiMac"] = "none" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", + return_value=device_info, + ): + # Updated + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == RESULT_ALREADY_CONFIGURED + + # ensure mac wasn't updated with "none" + assert entry.data[CONF_MAC] is None + assert entry.unique_id == "123" + + device_info = deepcopy(MOCK_DEVICE_INFO) + device_info["device"]["wifiMac"] = "aa:bb:cc:dd:ee:ff" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", + return_value=device_info, + ): + # Updated + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == RESULT_ALREADY_CONFIGURED + + # ensure mac was updated with new wifiMac value + assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" + assert entry.unique_id == "123" From 96e9a57fa373f09dde69c06df66e5793ba1f8a32 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 6 Nov 2023 22:47:39 +0000 Subject: [PATCH 274/982] Add processes count sensor to System Bridge (#103516) * Add processes count sensor to System Bridge * Add processes string --- homeassistant/components/system_bridge/const.py | 1 + homeassistant/components/system_bridge/coordinator.py | 2 ++ homeassistant/components/system_bridge/sensor.py | 7 +++++++ homeassistant/components/system_bridge/strings.json | 3 +++ 4 files changed, 13 insertions(+) diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 77ff953b67d..fc87b609b78 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -10,5 +10,6 @@ MODULES = [ "gpu", "media", "memory", + "processes", "system", ] diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 938b7d79b83..5a606721b00 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -26,6 +26,7 @@ from systembridgemodels.media_files import File as MediaFile, MediaFiles from systembridgemodels.media_get_file import MediaGetFile from systembridgemodels.media_get_files import MediaGetFiles from systembridgemodels.memory import Memory +from systembridgemodels.processes import Processes from systembridgemodels.register_data_listener import RegisterDataListener from systembridgemodels.system import System @@ -53,6 +54,7 @@ class SystemBridgeCoordinatorData(BaseModel): gpu: Gpu = None media: Media = None memory: Memory = None + processes: Processes = None system: System = None diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 9c12e14e264..e3fd2c14654 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -219,6 +219,13 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), + SystemBridgeSensorEntityDescription( + key="processes_count", + translation_key="processes", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:counter", + value=lambda data: int(data.processes.count), + ), SystemBridgeSensorEntityDescription( key="processes_load", translation_key="load", diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index 4df539f11d4..d99a2cf4588 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -65,6 +65,9 @@ "os": { "name": "Operating system" }, + "processes": { + "name": "Processes" + }, "load": { "name": "Load" }, From 9c0bfc1b582e965ce236e2a02274a091f72e0be6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Nov 2023 23:56:18 +0100 Subject: [PATCH 275/982] Bump reolink_aio to 0.7.15 (#103548) --- 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 9189de89efa..58785c1d795 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.7.14"] + "requirements": ["reolink-aio==0.7.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 994f2fc459b..2d28dabbd1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2325,7 +2325,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.14 +reolink-aio==0.7.15 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 914398e180b..77b3e816ea4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1736,7 +1736,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.14 +reolink-aio==0.7.15 # homeassistant.components.rflink rflink==0.0.65 From 53b15fd16db6dcf157aa76023459867b664a02b4 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 6 Nov 2023 16:25:00 -0700 Subject: [PATCH 276/982] Allow WeatherFlow devices to be removed (#103556) Allow WeatherFlow devices to be removed if they haven't reported --- homeassistant/components/weatherflow/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py index c64450babe7..fbd206b63f5 100644 --- a/homeassistant/components/weatherflow/__init__.py +++ b/homeassistant/components/weatherflow/__init__.py @@ -9,6 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.start import async_at_started @@ -75,3 +76,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await client.stop_listening() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + client: WeatherFlowListener = hass.data[DOMAIN][config_entry.entry_id] + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + for device in client.devices + if device.serial_number == identifier[1] + ) From b372a64057dcf88a373ff0b839c7fe61b1d4161d Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Mon, 6 Nov 2023 18:53:44 -0500 Subject: [PATCH 277/982] Bump pyenphase to 1.14.2 (#103553) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 4cffcce2d5c..718c33d2811 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.14.1"], + "requirements": ["pyenphase==1.14.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 2d28dabbd1d..84d84da82c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1699,7 +1699,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.1 +pyenphase==1.14.2 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77b3e816ea4..fc04a97760a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1281,7 +1281,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.1 +pyenphase==1.14.2 # homeassistant.components.everlights pyeverlights==0.1.0 From 9a776d958cb4c51ae8a76ece89d6242af8eb7845 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 7 Nov 2023 08:18:25 +0100 Subject: [PATCH 278/982] Update pvo to 2.1.0 (#103551) --- .../components/pvoutput/diagnostics.py | 5 +--- .../components/pvoutput/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/pvoutput/conftest.py | 27 +++++-------------- .../components/pvoutput/fixtures/status.json | 11 ++++++++ .../components/pvoutput/fixtures/system.json | 18 +++++++++++++ .../pvoutput/snapshots/test_diagnostics.ambr | 14 ++++++++++ tests/components/pvoutput/test_diagnostics.py | 20 +++++--------- tests/components/pvoutput/test_sensor.py | 8 +++--- 10 files changed, 65 insertions(+), 44 deletions(-) create mode 100644 tests/components/pvoutput/fixtures/status.json create mode 100644 tests/components/pvoutput/fixtures/system.json create mode 100644 tests/components/pvoutput/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/pvoutput/diagnostics.py b/homeassistant/components/pvoutput/diagnostics.py index 2aff3b20442..dfe215b7ddd 100644 --- a/homeassistant/components/pvoutput/diagnostics.py +++ b/homeassistant/components/pvoutput/diagnostics.py @@ -1,7 +1,6 @@ """Diagnostics support for PVOutput.""" from __future__ import annotations -import json from typing import Any from homeassistant.config_entries import ConfigEntry @@ -16,6 +15,4 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - # Round-trip via JSON to trigger serialization - data: dict[str, Any] = json.loads(coordinator.data.json()) - return data + return coordinator.data.to_dict() diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 787e59db3db..9e66d79d2bd 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==2.0.0"] + "requirements": ["pvo==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84d84da82c0..9b8cdb0640f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1509,7 +1509,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.0.0 +pvo==2.1.0 # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc04a97760a..09c457191e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1154,7 +1154,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.0.0 +pvo==2.1.0 # homeassistant.components.canary py-canary==0.5.3 diff --git a/tests/components/pvoutput/conftest.py b/tests/components/pvoutput/conftest.py index 844bf157342..f99aee031e9 100644 --- a/tests/components/pvoutput/conftest.py +++ b/tests/components/pvoutput/conftest.py @@ -11,7 +11,7 @@ from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -46,29 +46,16 @@ def mock_pvoutput_config_flow() -> Generator[None, MagicMock, None]: @pytest.fixture def mock_pvoutput() -> Generator[None, MagicMock, None]: """Return a mocked PVOutput client.""" - status = Status( - reported_date="20211229", - reported_time="22:37", - energy_consumption=1000, - energy_generation=500, - normalized_output=0.5, - power_consumption=2500, - power_generation=1500, - temperature=20.2, - voltage=220.5, - ) - - system = System( - inverter_brand="Super Inverters Inc.", - system_name="Frenck's Solar Farm", - ) - with patch( "homeassistant.components.pvoutput.coordinator.PVOutput", autospec=True ) as pvoutput_mock: pvoutput = pvoutput_mock.return_value - pvoutput.status.return_value = status - pvoutput.system.return_value = system + pvoutput.status.return_value = Status.from_dict( + load_json_object_fixture("status.json", DOMAIN) + ) + pvoutput.system.return_value = System.from_dict( + load_json_object_fixture("system.json", DOMAIN) + ) yield pvoutput diff --git a/tests/components/pvoutput/fixtures/status.json b/tests/components/pvoutput/fixtures/status.json new file mode 100644 index 00000000000..82dfb31c544 --- /dev/null +++ b/tests/components/pvoutput/fixtures/status.json @@ -0,0 +1,11 @@ +{ + "energy_consumption": 1000, + "energy_generation": 500, + "normalized_output": 0.5, + "power_consumption": 2500, + "power_generation": 1500, + "reported_date": "20210101", + "reported_time": "22:37", + "temperature": 20.2, + "voltage": 220.5 +} diff --git a/tests/components/pvoutput/fixtures/system.json b/tests/components/pvoutput/fixtures/system.json new file mode 100644 index 00000000000..c7b14c80609 --- /dev/null +++ b/tests/components/pvoutput/fixtures/system.json @@ -0,0 +1,18 @@ +{ + "array_tilt": 30, + "install_date": "20210101", + "inverter_brand": "Super Inverters Inc.", + "inverter_power": 5000, + "inverters": 1, + "latitude": 52.0, + "longitude": 4.0, + "orientation": "N", + "panel_brand": "Super Panels Inc.", + "panel_power": 250, + "panels": 20, + "shade": 0.1, + "status_interval": 5, + "system_name": "Frenck's Solar Farm", + "system_size": 5, + "zipcode": 1234 +} diff --git a/tests/components/pvoutput/snapshots/test_diagnostics.ambr b/tests/components/pvoutput/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..6ca0ce43b8d --- /dev/null +++ b/tests/components/pvoutput/snapshots/test_diagnostics.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'energy_consumption': 1000.0, + 'energy_generation': 500.0, + 'normalized_output': 0.5, + 'power_consumption': 2500.0, + 'power_generation': 1500.0, + 'reported_date': '20210101', + 'reported_time': '22:37:00', + 'temperature': 20.2, + 'voltage': 220.5, + }) +# --- diff --git a/tests/components/pvoutput/test_diagnostics.py b/tests/components/pvoutput/test_diagnostics.py index 1a0c0f1148b..1ac342bc850 100644 --- a/tests/components/pvoutput/test_diagnostics.py +++ b/tests/components/pvoutput/test_diagnostics.py @@ -1,5 +1,7 @@ """Tests for the diagnostics data provided by the PVOutput integration.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -11,18 +13,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "energy_consumption": 1000, - "energy_generation": 500, - "normalized_output": 0.5, - "power_consumption": 2500, - "power_generation": 1500, - "reported_date": "2021-12-29", - "reported_time": "22:37:00", - "temperature": 20.2, - "voltage": 220.5, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/pvoutput/test_sensor.py b/tests/components/pvoutput/test_sensor.py index afba339195a..61f55e1f552 100644 --- a/tests/components/pvoutput/test_sensor.py +++ b/tests/components/pvoutput/test_sensor.py @@ -35,7 +35,7 @@ async def test_sensors( assert state assert entry.unique_id == "12345_energy_consumption" assert entry.entity_category is None - assert state.state == "1000" + assert state.state == "1000.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ( state.attributes.get(ATTR_FRIENDLY_NAME) @@ -51,7 +51,7 @@ async def test_sensors( assert state assert entry.unique_id == "12345_energy_generation" assert entry.entity_category is None - assert state.state == "500" + assert state.state == "500.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ( state.attributes.get(ATTR_FRIENDLY_NAME) @@ -83,7 +83,7 @@ async def test_sensors( assert state assert entry.unique_id == "12345_power_consumption" assert entry.entity_category is None - assert state.state == "2500" + assert state.state == "2500.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's Solar Farm Power consumed" @@ -98,7 +98,7 @@ async def test_sensors( assert state assert entry.unique_id == "12345_power_generation" assert entry.entity_category is None - assert state.state == "1500" + assert state.state == "1500.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ( state.attributes.get(ATTR_FRIENDLY_NAME) From 9a1173f6a68047f197b0725ae74665ee2a7215cc Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Nov 2023 08:44:24 +0100 Subject: [PATCH 279/982] Add diagnostics tests to Overkiz integration (#103560) Co-authored-by: Joost Lekkerkerker --- tests/components/overkiz/__init__.py | 14 + tests/components/overkiz/conftest.py | 50 +- .../overkiz/fixtures/setup_tahoma_switch.json | 891 ++++++++ .../overkiz/snapshots/test_diagnostics.ambr | 1931 +++++++++++++++++ tests/components/overkiz/test_diagnostics.py | 63 + 5 files changed, 2947 insertions(+), 2 deletions(-) create mode 100644 tests/components/overkiz/fixtures/setup_tahoma_switch.json create mode 100644 tests/components/overkiz/snapshots/test_diagnostics.ambr create mode 100644 tests/components/overkiz/test_diagnostics.py diff --git a/tests/components/overkiz/__init__.py b/tests/components/overkiz/__init__.py index d827bcb8334..407527b619e 100644 --- a/tests/components/overkiz/__init__.py +++ b/tests/components/overkiz/__init__.py @@ -1 +1,15 @@ """Tests for the overkiz component.""" +import humps +from pyoverkiz.models import Setup + +from tests.common import load_json_object_fixture + + +def load_setup_fixture( + fixture: str = "overkiz/setup_tahoma_switch.json", +) -> Setup: + """Return setup from fixture.""" + setup_json = load_json_object_fixture(fixture) + setup = Setup(**humps.decamelize(setup_json)) + + return setup diff --git a/tests/components/overkiz/conftest.py b/tests/components/overkiz/conftest.py index 6e00b6f5fe2..990b88d84ed 100644 --- a/tests/components/overkiz/conftest.py +++ b/tests/components/overkiz/conftest.py @@ -1,14 +1,60 @@ """Configuration for overkiz tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.overkiz import load_setup_fixture +from tests.components.overkiz.test_config_flow import ( + TEST_EMAIL, + TEST_GATEWAY_ID, + TEST_HUB, + TEST_PASSWORD, +) + +MOCK_SETUP_RESPONSE = Mock(devices=[], gateways=[]) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Somfy TaHoma Switch", + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" + """Mock setting up a config entry.""" with patch( "homeassistant.components.overkiz.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the Overkiz integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_setup=AsyncMock(return_value=load_setup_fixture()), + get_scenarios=AsyncMock(return_value=[]), + fetch_events=AsyncMock(return_value=[]), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/overkiz/fixtures/setup_tahoma_switch.json b/tests/components/overkiz/fixtures/setup_tahoma_switch.json new file mode 100644 index 00000000000..6b5d8beb7f9 --- /dev/null +++ b/tests/components/overkiz/fixtures/setup_tahoma_switch.json @@ -0,0 +1,891 @@ +{ + "creationTime": 1665238624000, + "lastUpdateTime": 1665238624000, + "id": "SETUP-****-****-6867", + "location": { + "creationTime": 1665238624000, + "lastUpdateTime": 1667054735000, + "city": "** **", + "country": "**", + "postalCode": "** **", + "addressLine1": "** **", + "addressLine2": "*", + "timezone": "Europe/Amsterdam", + "longitude": "**", + "latitude": "**", + "twilightMode": 2, + "twilightAngle": "SOLAR", + "twilightCity": "amsterdam", + "summerSolsticeDuskMinutes": 1290, + "winterSolsticeDuskMinutes": 990, + "twilightOffsetEnabled": false, + "dawnOffset": 0, + "duskOffset": 0, + "countryCode": "NL" + }, + "gateways": [ + { + "gatewayId": "****-****-6867", + "type": 98, + "subType": 1, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "autoUpdateEnabled": true, + "alive": true, + "timeReliable": true, + "connectivity": { + "status": "OK", + "protocolVersion": "2023.4.4" + }, + "upToDate": true, + "updateStatus": "UP_TO_DATE", + "syncInProgress": false, + "mode": "ACTIVE", + "functions": "INTERNET_AUTHORIZATION,SCENARIO_DOWNLOAD,SCENARIO_AUTO_LAUNCHING,SCENARIO_TELECO_LAUNCHING,INTERNET_UPLOAD,INTERNET_UPDATE,TRIGGERS_SENSORS" + } + ], + "devices": [ + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "** *(**)*", + "deviceURL": "homekit://****-****-6867/stack", + "shortcut": false, + "controllableName": "homekit:StackComponent", + "definition": { + "commands": [ + { + "commandName": "deleteControllers", + "nparams": 0 + } + ], + "states": [], + "dataProperties": [], + "widgetName": "HomekitStack", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "homekit:StackComponent", + "type": "PROTOCOL_GATEWAY" + }, + "attributes": [ + { + "name": "homekit:SetupPayload", + "type": 3, + "value": "**:*/*/**" + }, + { + "name": "homekit:SetupCode", + "type": 3, + "value": "**" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 5, + "widget": "HomekitStack", + "oid": "ab964849-56ca-4e9c-a58c-33ce5e341b68", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "**", + "deviceURL": "internal://****-****-6867/pod/0", + "shortcut": false, + "controllableName": "internal:PodV3Component", + "definition": { + "commands": [ + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "update", + "nparams": 0 + }, + { + "commandName": "setCountryCode", + "nparams": 1 + }, + { + "commandName": "activateCalendar", + "nparams": 0 + }, + { + "commandName": "deactivateCalendar", + "nparams": 0 + }, + { + "commandName": "refreshPodMode", + "nparams": 0 + }, + { + "commandName": "refreshUpdateStatus", + "nparams": 0 + }, + { + "commandName": "setCalendar", + "nparams": 1 + }, + { + "commandName": "setLightingLedPodMode", + "nparams": 1 + }, + { + "commandName": "setPodLedOff", + "nparams": 0 + }, + { + "commandName": "setPodLedOn", + "nparams": 0 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["offline", "online"], + "qualifiedName": "core:ConnectivityState" + }, + { + "type": "DataState", + "qualifiedName": "core:CountryCodeState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:LocalAccessProofState" + }, + { + "type": "DataState", + "qualifiedName": "core:LocalIPv4AddressState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": ["pressed", "stop"], + "qualifiedName": "internal:Button1State" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": ["pressed", "stop"], + "qualifiedName": "internal:Button2State" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": ["pressed", "stop"], + "qualifiedName": "internal:Button3State" + }, + { + "type": "ContinuousState", + "qualifiedName": "internal:LightingLedPodModeState" + } + ], + "dataProperties": [], + "widgetName": "Pod", + "uiProfiles": ["UpdatableComponent"], + "uiClass": "Pod", + "qualifiedName": "internal:PodV3Component", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "internal:LightingLedPodModeState", + "type": 2, + "value": 1 + }, + { + "name": "core:CountryCodeState", + "type": 3, + "value": "NL" + }, + { + "name": "internal:Button1State", + "type": 3, + "value": "pressed" + }, + { + "name": "internal:Button3State", + "type": 3, + "value": "stop" + }, + { + "name": "core:LocalAccessProofState", + "type": 3, + "value": "localAccessProof" + }, + { + "name": "core:LocalIPv4AddressState", + "type": 3, + "value": "192.168.1.42" + }, + { + "name": "core:NameState", + "type": 3, + "value": "**" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "Pod", + "oid": "c79a8bf6-59d6-434e-8cfd-97193541fa17", + "uiClass": "Pod" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "** *(**/**)*", + "deviceURL": "internal://****-****-6867/wifi/0", + "shortcut": false, + "controllableName": "internal:WifiComponent", + "definition": { + "commands": [ + { + "commandName": "clearCredentials", + "nparams": 0 + }, + { + "commandName": "setTargetInfraConfig", + "nparams": 2 + }, + { + "commandName": "setWifiMode", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "internal:CurrentInfraConfigState" + }, + { + "type": "ContinuousState", + "qualifiedName": "internal:SignalStrengthState" + }, + { + "type": "DataState", + "qualifiedName": "internal:WifiModeState" + } + ], + "dataProperties": [], + "widgetName": "Wifi", + "uiProfiles": ["Specific"], + "uiClass": "Wifi", + "qualifiedName": "internal:WifiComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "internal:WifiModeState", + "type": 3, + "value": "infrastructure" + }, + { + "name": "internal:CurrentInfraConfigState", + "type": 3, + "value": "AM" + }, + { + "name": "internal:SignalStrengthState", + "type": 1, + "value": 69 + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "Wifi", + "oid": "4272c61b-5493-453c-8d87-a58e45ef60f8", + "uiClass": "Wifi" + }, + { + "creationTime": 1665238924000, + "lastUpdateTime": 1665238924000, + "label": "** *(**)*", + "deviceURL": "io://****-****-6867/4167385", + "shortcut": false, + "controllableName": "io:StackComponent", + "definition": { + "commands": [ + { + "commandName": "advancedSomfyDiscover", + "nparams": 1 + }, + { + "commandName": "discover1WayController", + "nparams": 2 + }, + { + "commandName": "discoverActuators", + "nparams": 1 + }, + { + "commandName": "discoverSensors", + "nparams": 1 + }, + { + "commandName": "discoverSomfyUnsetActuators", + "nparams": 0 + }, + { + "commandName": "joinNetwork", + "nparams": 0 + }, + { + "commandName": "resetNetworkSecurity", + "nparams": 0 + }, + { + "commandName": "shareNetwork", + "nparams": 0 + } + ], + "states": [], + "dataProperties": [], + "widgetName": "IOStack", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "io:StackComponent", + "type": "PROTOCOL_GATEWAY" + }, + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 5, + "widget": "IOStack", + "oid": "bb301e56-0957-417f-ba37-26efc11659aa", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238637000, + "lastUpdateTime": 1665238637000, + "label": "** ** **", + "deviceURL": "ogp://****-****-6867/00000BE8", + "shortcut": false, + "controllableName": "ogp:Bridge", + "definition": { + "commands": [ + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "sendPrivate", + "nparams": 1 + }, + { + "commandName": "setName", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:Private10State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private1State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private2State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private3State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private4State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private5State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private6State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private7State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private8State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private9State" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + } + ], + "dataProperties": [], + "widgetName": "DynamicBridge", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "ogp:Bridge", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "** ** **" + } + ], + "attributes": [ + { + "name": "core:Technology", + "type": 3, + "value": "KNX" + }, + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "OGP KNX Bridge" + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "private" + }, + { + "name": "identification" + } + ] + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Overkiz" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "DynamicBridge", + "oid": "e88717c3-02a9-49b6-a5a5-5adca558afe9", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238799000, + "lastUpdateTime": 1665238799000, + "label": "** ** **", + "deviceURL": "ogp://****-****-6867/0003FEF3", + "shortcut": false, + "controllableName": "ogp:Bridge", + "definition": { + "commands": [ + { + "commandName": "discover", + "nparams": 0 + }, + { + "commandName": "reset", + "nparams": 0 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + } + ], + "dataProperties": [], + "widgetName": "DynamicBridge", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "ogp:Bridge", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "** ** **" + } + ], + "attributes": [ + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "OGP Sonos Bridge" + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Overkiz" + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "identification", + "commandLess": true + }, + { + "name": "discovery" + }, + { + "name": "reset" + } + ] + }, + { + "name": "core:Technology", + "type": 3, + "value": "Sonos" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "DynamicBridge", + "oid": "4031915f-df40-4a70-a97f-64031182a507", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238637000, + "lastUpdateTime": 1665238637000, + "label": "** ** **", + "deviceURL": "ogp://****-****-6867/039575E9", + "shortcut": false, + "controllableName": "ogp:Bridge", + "definition": { + "commands": [ + { + "commandName": "discover", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + } + ], + "dataProperties": [], + "widgetName": "DynamicBridge", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "ogp:Bridge", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "** ** **" + } + ], + "attributes": [ + { + "name": "core:Manufacturer", + "type": 3, + "value": "Overkiz" + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "discovery" + }, + { + "name": "identification" + } + ] + }, + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "OGP Siegenia Bridge" + }, + { + "name": "core:Technology", + "type": 3, + "value": "Siegenia" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "DynamicBridge", + "oid": "5cdf0023-2d7e-4e8e-bfb0-74ebb6ebe0eb", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238637000, + "lastUpdateTime": 1665238637000, + "label": "** ** **", + "deviceURL": "ogp://****-****-6867/09E45393", + "shortcut": false, + "controllableName": "ogp:Bridge", + "definition": { + "commands": [ + { + "commandName": "discover", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + } + ], + "dataProperties": [], + "widgetName": "DynamicBridge", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "ogp:Bridge", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "** ** **" + } + ], + "attributes": [ + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "OGP Intesis Bridge" + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "discovery" + }, + { + "name": "identification" + } + ] + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Overkiz" + }, + { + "name": "core:Technology", + "type": 3, + "value": "Intesis" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "DynamicBridge", + "oid": "b1f5dc24-058e-4cb2-a052-7b2d5a7de7a5", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1667840384000, + "lastUpdateTime": 1667840384000, + "label": "** ** **", + "deviceURL": "rts://****-****-6867/16756006", + "shortcut": false, + "controllableName": "rts:RollerShutterRTSComponent", + "definition": { + "commands": [ + { + "commandName": "close", + "nparams": 1 + }, + { + "commandName": "down", + "nparams": 1 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "my", + "nparams": 1 + }, + { + "commandName": "open", + "nparams": 1 + }, + { + "commandName": "rest", + "nparams": 1 + }, + { + "commandName": "stop", + "nparams": 1 + }, + { + "commandName": "test", + "nparams": 0 + }, + { + "commandName": "up", + "nparams": 1 + }, + { + "commandName": "openConfiguration", + "nparams": 1 + } + ], + "states": [], + "dataProperties": [ + { + "value": "0", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "UpDownRollerShutter", + "uiProfiles": ["OpenCloseShutter", "OpenClose"], + "uiClass": "RollerShutter", + "qualifiedName": "rts:RollerShutterRTSComponent", + "type": "ACTUATOR" + }, + "attributes": [ + { + "name": "rts:diy", + "type": 6, + "value": true + } + ], + "available": true, + "enabled": true, + "placeOID": "9e3d6899-50bb-4869-9c5e-46c2b57f7c9e", + "type": 1, + "widget": "UpDownRollerShutter", + "oid": "1a10d6f6-9bc3-40f3-a33c-e383fd41d3e8", + "uiClass": "RollerShutter" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "** *(**)*", + "deviceURL": "zigbee://****-****-6867/65535", + "shortcut": false, + "controllableName": "zigbee:TransceiverV3_0Component", + "definition": { + "commands": [], + "states": [], + "dataProperties": [], + "widgetName": "ZigbeeStack", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "zigbee:TransceiverV3_0Component", + "type": "PROTOCOL_GATEWAY" + }, + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 5, + "widget": "ZigbeeStack", + "oid": "1629c223-d115-4aad-808a-373f428d9c27", + "uiClass": "ProtocolGateway" + } + ], + "zones": [], + "resellerDelegationType": "NEVER", + "disconnectionConfiguration": { + "notificationTitle": "[User] : Your Somfy box is disconnected", + "notificationText": "Your Somfy box is disconnected", + "targetPushSubscriptions": ["8849df9a-b61a-498f-ab81-67a767adba31"], + "notificationType": "PUSH" + }, + "oid": "15eaf55a-8af9-483b-ae4a-ffd4254fd762", + "rootPlace": { + "creationTime": 1665238624000, + "lastUpdateTime": 1665238630000, + "label": "** **", + "type": 200, + "oid": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "subPlaces": [ + { + "creationTime": 1667840432000, + "lastUpdateTime": 1667840432000, + "label": "**", + "type": 108, + "metadata": "{\"color\":\"#08C27F\"}", + "oid": "9e3d6899-50bb-4869-9c5e-46c2b57f7c9e", + "subPlaces": [] + } + ] + }, + "features": [] +} diff --git a/tests/components/overkiz/snapshots/test_diagnostics.ambr b/tests/components/overkiz/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..06a456f88af --- /dev/null +++ b/tests/components/overkiz/snapshots/test_diagnostics.ambr @@ -0,0 +1,1931 @@ +# serializer version: 1 +# name: test_device_diagnostics + dict({ + 'device': dict({ + 'controllable_name': 'rts:RollerShutterRTSComponent', + 'device_url': 'rts://****-****-6867/16756006', + 'firmware': None, + 'model': 'UpDownRollerShutter', + }), + 'execution_history': list([ + ]), + 'server': 'somfy_europe', + 'setup': dict({ + 'creationTime': 1665238624000, + 'devices': list([ + dict({ + 'attributes': list([ + dict({ + 'name': 'homekit:SetupPayload', + 'type': 3, + 'value': '**:*/*/**', + }), + dict({ + 'name': 'homekit:SetupCode', + 'type': 3, + 'value': '**', + }), + ]), + 'available': True, + 'controllableName': 'homekit:StackComponent', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'deleteControllers', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'homekit:StackComponent', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'HomekitStack', + }), + 'deviceURL': 'homekit://****-****-6867/stack', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238630000, + 'oid': 'ab964849-56ca-4e9c-a58c-33ce5e341b68', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'HomekitStack', + }), + dict({ + 'available': True, + 'controllableName': 'internal:PodV3Component', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'getName', + 'nparams': 0, + }), + dict({ + 'commandName': 'update', + 'nparams': 0, + }), + dict({ + 'commandName': 'setCountryCode', + 'nparams': 1, + }), + dict({ + 'commandName': 'activateCalendar', + 'nparams': 0, + }), + dict({ + 'commandName': 'deactivateCalendar', + 'nparams': 0, + }), + dict({ + 'commandName': 'refreshPodMode', + 'nparams': 0, + }), + dict({ + 'commandName': 'refreshUpdateStatus', + 'nparams': 0, + }), + dict({ + 'commandName': 'setCalendar', + 'nparams': 1, + }), + dict({ + 'commandName': 'setLightingLedPodMode', + 'nparams': 1, + }), + dict({ + 'commandName': 'setPodLedOff', + 'nparams': 0, + }), + dict({ + 'commandName': 'setPodLedOn', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'internal:PodV3Component', + 'states': list([ + dict({ + 'qualifiedName': 'core:ConnectivityState', + 'type': 'DiscreteState', + 'values': list([ + 'offline', + 'online', + ]), + }), + dict({ + 'qualifiedName': 'core:CountryCodeState', + 'type': 'DataState', + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'core:LocalAccessProofState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:LocalIPv4AddressState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button1State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button2State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button3State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'qualifiedName': 'internal:LightingLedPodModeState', + 'type': 'ContinuousState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'Pod', + 'uiProfiles': list([ + 'UpdatableComponent', + ]), + 'widgetName': 'Pod', + }), + 'deviceURL': 'internal://****-****-6867/pod/0', + 'enabled': True, + 'label': '**', + 'lastUpdateTime': 1665238630000, + 'oid': 'c79a8bf6-59d6-434e-8cfd-97193541fa17', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'internal:LightingLedPodModeState', + 'type': 2, + 'value': 1, + }), + dict({ + 'name': 'core:CountryCodeState', + 'type': 3, + 'value': 'NL', + }), + dict({ + 'name': 'internal:Button1State', + 'type': 3, + 'value': 'pressed', + }), + dict({ + 'name': 'internal:Button3State', + 'type': 3, + 'value': 'stop', + }), + dict({ + 'name': 'core:LocalAccessProofState', + 'type': 3, + 'value': 'localAccessProof', + }), + dict({ + 'name': 'core:LocalIPv4AddressState', + 'type': 3, + 'value': '192.168.1.42', + }), + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '**', + }), + ]), + 'type': 1, + 'uiClass': 'Pod', + 'widget': 'Pod', + }), + dict({ + 'available': True, + 'controllableName': 'internal:WifiComponent', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'clearCredentials', + 'nparams': 0, + }), + dict({ + 'commandName': 'setTargetInfraConfig', + 'nparams': 2, + }), + dict({ + 'commandName': 'setWifiMode', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'internal:WifiComponent', + 'states': list([ + dict({ + 'qualifiedName': 'internal:CurrentInfraConfigState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'internal:SignalStrengthState', + 'type': 'ContinuousState', + }), + dict({ + 'qualifiedName': 'internal:WifiModeState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'Wifi', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'Wifi', + }), + 'deviceURL': 'internal://****-****-6867/wifi/0', + 'enabled': True, + 'label': '** *(**/**)*', + 'lastUpdateTime': 1665238630000, + 'oid': '4272c61b-5493-453c-8d87-a58e45ef60f8', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'internal:WifiModeState', + 'type': 3, + 'value': 'infrastructure', + }), + dict({ + 'name': 'internal:CurrentInfraConfigState', + 'type': 3, + 'value': 'AM', + }), + dict({ + 'name': 'internal:SignalStrengthState', + 'type': 1, + 'value': 69, + }), + ]), + 'type': 1, + 'uiClass': 'Wifi', + 'widget': 'Wifi', + }), + dict({ + 'available': True, + 'controllableName': 'io:StackComponent', + 'creationTime': 1665238924000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'advancedSomfyDiscover', + 'nparams': 1, + }), + dict({ + 'commandName': 'discover1WayController', + 'nparams': 2, + }), + dict({ + 'commandName': 'discoverActuators', + 'nparams': 1, + }), + dict({ + 'commandName': 'discoverSensors', + 'nparams': 1, + }), + dict({ + 'commandName': 'discoverSomfyUnsetActuators', + 'nparams': 0, + }), + dict({ + 'commandName': 'joinNetwork', + 'nparams': 0, + }), + dict({ + 'commandName': 'resetNetworkSecurity', + 'nparams': 0, + }), + dict({ + 'commandName': 'shareNetwork', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'io:StackComponent', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'IOStack', + }), + 'deviceURL': 'io://****-****-6867/4167385', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238924000, + 'oid': 'bb301e56-0957-417f-ba37-26efc11659aa', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'IOStack', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'KNX', + }), + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP KNX Bridge', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'private', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'sendPrivate', + 'nparams': 1, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private10State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private1State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private2State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private3State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private4State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private5State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private6State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private7State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private8State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private9State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/00000BE8', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': 'e88717c3-02a9-49b6-a5a5-5adca558afe9', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Sonos Bridge', + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'commandLess': True, + 'name': 'identification', + }), + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'reset', + }), + ]), + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Sonos', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238799000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'reset', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/0003FEF3', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238799000, + 'oid': '4031915f-df40-4a70-a97f-64031182a507', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Siegenia Bridge', + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Siegenia', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/039575E9', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': '5cdf0023-2d7e-4e8e-bfb0-74ebb6ebe0eb', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Intesis Bridge', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Intesis', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/09E45393', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': 'b1f5dc24-058e-4cb2-a052-7b2d5a7de7a5', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'rts:diy', + 'type': 6, + 'value': True, + }), + ]), + 'available': True, + 'controllableName': 'rts:RollerShutterRTSComponent', + 'creationTime': 1667840384000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'close', + 'nparams': 1, + }), + dict({ + 'commandName': 'down', + 'nparams': 1, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'my', + 'nparams': 1, + }), + dict({ + 'commandName': 'open', + 'nparams': 1, + }), + dict({ + 'commandName': 'rest', + 'nparams': 1, + }), + dict({ + 'commandName': 'stop', + 'nparams': 1, + }), + dict({ + 'commandName': 'test', + 'nparams': 0, + }), + dict({ + 'commandName': 'up', + 'nparams': 1, + }), + dict({ + 'commandName': 'openConfiguration', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + dict({ + 'qualifiedName': 'core:identifyInterval', + 'value': '0', + }), + ]), + 'qualifiedName': 'rts:RollerShutterRTSComponent', + 'states': list([ + ]), + 'type': 'ACTUATOR', + 'uiClass': 'RollerShutter', + 'uiProfiles': list([ + 'OpenCloseShutter', + 'OpenClose', + ]), + 'widgetName': 'UpDownRollerShutter', + }), + 'deviceURL': 'rts://****-****-6867/16756006', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1667840384000, + 'oid': '1a10d6f6-9bc3-40f3-a33c-e383fd41d3e8', + 'placeOID': '9e3d6899-50bb-4869-9c5e-46c2b57f7c9e', + 'shortcut': False, + 'type': 1, + 'uiClass': 'RollerShutter', + 'widget': 'UpDownRollerShutter', + }), + dict({ + 'available': True, + 'controllableName': 'zigbee:TransceiverV3_0Component', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'zigbee:TransceiverV3_0Component', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'ZigbeeStack', + }), + 'deviceURL': 'zigbee://****-****-6867/65535', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238630000, + 'oid': '1629c223-d115-4aad-808a-373f428d9c27', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'ZigbeeStack', + }), + ]), + 'disconnectionConfiguration': dict({ + 'notificationText': 'Your Somfy box is disconnected', + 'notificationTitle': '[User] : Your Somfy box is disconnected', + 'notificationType': 'PUSH', + 'targetPushSubscriptions': list([ + '8849df9a-b61a-498f-ab81-67a767adba31', + ]), + }), + 'features': list([ + ]), + 'gateways': list([ + dict({ + 'alive': True, + 'autoUpdateEnabled': True, + 'connectivity': dict({ + 'protocolVersion': '2023.4.4', + 'status': 'OK', + }), + 'functions': 'INTERNET_AUTHORIZATION,SCENARIO_DOWNLOAD,SCENARIO_AUTO_LAUNCHING,SCENARIO_TELECO_LAUNCHING,INTERNET_UPLOAD,INTERNET_UPDATE,TRIGGERS_SENSORS', + 'gatewayId': '****-****-6867', + 'mode': 'ACTIVE', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'subType': 1, + 'syncInProgress': False, + 'timeReliable': True, + 'type': 98, + 'upToDate': True, + 'updateStatus': 'UP_TO_DATE', + }), + ]), + 'id': 'SETUP-****-****-6867', + 'lastUpdateTime': 1665238624000, + 'location': dict({ + 'addressLine1': '** **', + 'addressLine2': '*', + 'city': '** **', + 'country': '**', + 'countryCode': 'NL', + 'creationTime': 1665238624000, + 'dawnOffset': 0, + 'duskOffset': 0, + 'lastUpdateTime': 1667054735000, + 'latitude': '**', + 'longitude': '**', + 'postalCode': '** **', + 'summerSolsticeDuskMinutes': 1290, + 'timezone': 'Europe/Amsterdam', + 'twilightAngle': 'SOLAR', + 'twilightCity': 'amsterdam', + 'twilightMode': 2, + 'twilightOffsetEnabled': False, + 'winterSolsticeDuskMinutes': 990, + }), + 'oid': '15eaf55a-8af9-483b-ae4a-ffd4254fd762', + 'resellerDelegationType': 'NEVER', + 'rootPlace': dict({ + 'creationTime': 1665238624000, + 'label': '** **', + 'lastUpdateTime': 1665238630000, + 'oid': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'subPlaces': list([ + dict({ + 'creationTime': 1667840432000, + 'label': '**', + 'lastUpdateTime': 1667840432000, + 'metadata': '{"color":"#08C27F"}', + 'oid': '9e3d6899-50bb-4869-9c5e-46c2b57f7c9e', + 'subPlaces': list([ + ]), + 'type': 108, + }), + ]), + 'type': 200, + }), + 'zones': list([ + ]), + }), + }) +# --- +# name: test_diagnostics + dict({ + 'execution_history': list([ + ]), + 'server': 'somfy_europe', + 'setup': dict({ + 'creationTime': 1665238624000, + 'devices': list([ + dict({ + 'attributes': list([ + dict({ + 'name': 'homekit:SetupPayload', + 'type': 3, + 'value': '**:*/*/**', + }), + dict({ + 'name': 'homekit:SetupCode', + 'type': 3, + 'value': '**', + }), + ]), + 'available': True, + 'controllableName': 'homekit:StackComponent', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'deleteControllers', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'homekit:StackComponent', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'HomekitStack', + }), + 'deviceURL': 'homekit://****-****-6867/stack', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238630000, + 'oid': 'ab964849-56ca-4e9c-a58c-33ce5e341b68', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'HomekitStack', + }), + dict({ + 'available': True, + 'controllableName': 'internal:PodV3Component', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'getName', + 'nparams': 0, + }), + dict({ + 'commandName': 'update', + 'nparams': 0, + }), + dict({ + 'commandName': 'setCountryCode', + 'nparams': 1, + }), + dict({ + 'commandName': 'activateCalendar', + 'nparams': 0, + }), + dict({ + 'commandName': 'deactivateCalendar', + 'nparams': 0, + }), + dict({ + 'commandName': 'refreshPodMode', + 'nparams': 0, + }), + dict({ + 'commandName': 'refreshUpdateStatus', + 'nparams': 0, + }), + dict({ + 'commandName': 'setCalendar', + 'nparams': 1, + }), + dict({ + 'commandName': 'setLightingLedPodMode', + 'nparams': 1, + }), + dict({ + 'commandName': 'setPodLedOff', + 'nparams': 0, + }), + dict({ + 'commandName': 'setPodLedOn', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'internal:PodV3Component', + 'states': list([ + dict({ + 'qualifiedName': 'core:ConnectivityState', + 'type': 'DiscreteState', + 'values': list([ + 'offline', + 'online', + ]), + }), + dict({ + 'qualifiedName': 'core:CountryCodeState', + 'type': 'DataState', + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'core:LocalAccessProofState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:LocalIPv4AddressState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button1State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button2State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button3State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'qualifiedName': 'internal:LightingLedPodModeState', + 'type': 'ContinuousState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'Pod', + 'uiProfiles': list([ + 'UpdatableComponent', + ]), + 'widgetName': 'Pod', + }), + 'deviceURL': 'internal://****-****-6867/pod/0', + 'enabled': True, + 'label': '**', + 'lastUpdateTime': 1665238630000, + 'oid': 'c79a8bf6-59d6-434e-8cfd-97193541fa17', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'internal:LightingLedPodModeState', + 'type': 2, + 'value': 1, + }), + dict({ + 'name': 'core:CountryCodeState', + 'type': 3, + 'value': 'NL', + }), + dict({ + 'name': 'internal:Button1State', + 'type': 3, + 'value': 'pressed', + }), + dict({ + 'name': 'internal:Button3State', + 'type': 3, + 'value': 'stop', + }), + dict({ + 'name': 'core:LocalAccessProofState', + 'type': 3, + 'value': 'localAccessProof', + }), + dict({ + 'name': 'core:LocalIPv4AddressState', + 'type': 3, + 'value': '192.168.1.42', + }), + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '**', + }), + ]), + 'type': 1, + 'uiClass': 'Pod', + 'widget': 'Pod', + }), + dict({ + 'available': True, + 'controllableName': 'internal:WifiComponent', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'clearCredentials', + 'nparams': 0, + }), + dict({ + 'commandName': 'setTargetInfraConfig', + 'nparams': 2, + }), + dict({ + 'commandName': 'setWifiMode', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'internal:WifiComponent', + 'states': list([ + dict({ + 'qualifiedName': 'internal:CurrentInfraConfigState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'internal:SignalStrengthState', + 'type': 'ContinuousState', + }), + dict({ + 'qualifiedName': 'internal:WifiModeState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'Wifi', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'Wifi', + }), + 'deviceURL': 'internal://****-****-6867/wifi/0', + 'enabled': True, + 'label': '** *(**/**)*', + 'lastUpdateTime': 1665238630000, + 'oid': '4272c61b-5493-453c-8d87-a58e45ef60f8', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'internal:WifiModeState', + 'type': 3, + 'value': 'infrastructure', + }), + dict({ + 'name': 'internal:CurrentInfraConfigState', + 'type': 3, + 'value': 'AM', + }), + dict({ + 'name': 'internal:SignalStrengthState', + 'type': 1, + 'value': 69, + }), + ]), + 'type': 1, + 'uiClass': 'Wifi', + 'widget': 'Wifi', + }), + dict({ + 'available': True, + 'controllableName': 'io:StackComponent', + 'creationTime': 1665238924000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'advancedSomfyDiscover', + 'nparams': 1, + }), + dict({ + 'commandName': 'discover1WayController', + 'nparams': 2, + }), + dict({ + 'commandName': 'discoverActuators', + 'nparams': 1, + }), + dict({ + 'commandName': 'discoverSensors', + 'nparams': 1, + }), + dict({ + 'commandName': 'discoverSomfyUnsetActuators', + 'nparams': 0, + }), + dict({ + 'commandName': 'joinNetwork', + 'nparams': 0, + }), + dict({ + 'commandName': 'resetNetworkSecurity', + 'nparams': 0, + }), + dict({ + 'commandName': 'shareNetwork', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'io:StackComponent', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'IOStack', + }), + 'deviceURL': 'io://****-****-6867/4167385', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238924000, + 'oid': 'bb301e56-0957-417f-ba37-26efc11659aa', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'IOStack', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'KNX', + }), + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP KNX Bridge', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'private', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'sendPrivate', + 'nparams': 1, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private10State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private1State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private2State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private3State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private4State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private5State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private6State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private7State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private8State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private9State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/00000BE8', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': 'e88717c3-02a9-49b6-a5a5-5adca558afe9', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Sonos Bridge', + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'commandLess': True, + 'name': 'identification', + }), + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'reset', + }), + ]), + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Sonos', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238799000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'reset', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/0003FEF3', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238799000, + 'oid': '4031915f-df40-4a70-a97f-64031182a507', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Siegenia Bridge', + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Siegenia', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/039575E9', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': '5cdf0023-2d7e-4e8e-bfb0-74ebb6ebe0eb', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Intesis Bridge', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Intesis', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/09E45393', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': 'b1f5dc24-058e-4cb2-a052-7b2d5a7de7a5', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'rts:diy', + 'type': 6, + 'value': True, + }), + ]), + 'available': True, + 'controllableName': 'rts:RollerShutterRTSComponent', + 'creationTime': 1667840384000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'close', + 'nparams': 1, + }), + dict({ + 'commandName': 'down', + 'nparams': 1, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'my', + 'nparams': 1, + }), + dict({ + 'commandName': 'open', + 'nparams': 1, + }), + dict({ + 'commandName': 'rest', + 'nparams': 1, + }), + dict({ + 'commandName': 'stop', + 'nparams': 1, + }), + dict({ + 'commandName': 'test', + 'nparams': 0, + }), + dict({ + 'commandName': 'up', + 'nparams': 1, + }), + dict({ + 'commandName': 'openConfiguration', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + dict({ + 'qualifiedName': 'core:identifyInterval', + 'value': '0', + }), + ]), + 'qualifiedName': 'rts:RollerShutterRTSComponent', + 'states': list([ + ]), + 'type': 'ACTUATOR', + 'uiClass': 'RollerShutter', + 'uiProfiles': list([ + 'OpenCloseShutter', + 'OpenClose', + ]), + 'widgetName': 'UpDownRollerShutter', + }), + 'deviceURL': 'rts://****-****-6867/16756006', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1667840384000, + 'oid': '1a10d6f6-9bc3-40f3-a33c-e383fd41d3e8', + 'placeOID': '9e3d6899-50bb-4869-9c5e-46c2b57f7c9e', + 'shortcut': False, + 'type': 1, + 'uiClass': 'RollerShutter', + 'widget': 'UpDownRollerShutter', + }), + dict({ + 'available': True, + 'controllableName': 'zigbee:TransceiverV3_0Component', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'zigbee:TransceiverV3_0Component', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'ZigbeeStack', + }), + 'deviceURL': 'zigbee://****-****-6867/65535', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238630000, + 'oid': '1629c223-d115-4aad-808a-373f428d9c27', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'ZigbeeStack', + }), + ]), + 'disconnectionConfiguration': dict({ + 'notificationText': 'Your Somfy box is disconnected', + 'notificationTitle': '[User] : Your Somfy box is disconnected', + 'notificationType': 'PUSH', + 'targetPushSubscriptions': list([ + '8849df9a-b61a-498f-ab81-67a767adba31', + ]), + }), + 'features': list([ + ]), + 'gateways': list([ + dict({ + 'alive': True, + 'autoUpdateEnabled': True, + 'connectivity': dict({ + 'protocolVersion': '2023.4.4', + 'status': 'OK', + }), + 'functions': 'INTERNET_AUTHORIZATION,SCENARIO_DOWNLOAD,SCENARIO_AUTO_LAUNCHING,SCENARIO_TELECO_LAUNCHING,INTERNET_UPLOAD,INTERNET_UPDATE,TRIGGERS_SENSORS', + 'gatewayId': '****-****-6867', + 'mode': 'ACTIVE', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'subType': 1, + 'syncInProgress': False, + 'timeReliable': True, + 'type': 98, + 'upToDate': True, + 'updateStatus': 'UP_TO_DATE', + }), + ]), + 'id': 'SETUP-****-****-6867', + 'lastUpdateTime': 1665238624000, + 'location': dict({ + 'addressLine1': '** **', + 'addressLine2': '*', + 'city': '** **', + 'country': '**', + 'countryCode': 'NL', + 'creationTime': 1665238624000, + 'dawnOffset': 0, + 'duskOffset': 0, + 'lastUpdateTime': 1667054735000, + 'latitude': '**', + 'longitude': '**', + 'postalCode': '** **', + 'summerSolsticeDuskMinutes': 1290, + 'timezone': 'Europe/Amsterdam', + 'twilightAngle': 'SOLAR', + 'twilightCity': 'amsterdam', + 'twilightMode': 2, + 'twilightOffsetEnabled': False, + 'winterSolsticeDuskMinutes': 990, + }), + 'oid': '15eaf55a-8af9-483b-ae4a-ffd4254fd762', + 'resellerDelegationType': 'NEVER', + 'rootPlace': dict({ + 'creationTime': 1665238624000, + 'label': '** **', + 'lastUpdateTime': 1665238630000, + 'oid': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'subPlaces': list([ + dict({ + 'creationTime': 1667840432000, + 'label': '**', + 'lastUpdateTime': 1667840432000, + 'metadata': '{"color":"#08C27F"}', + 'oid': '9e3d6899-50bb-4869-9c5e-46c2b57f7c9e', + 'subPlaces': list([ + ]), + 'type': 108, + }), + ]), + 'type': 200, + }), + 'zones': list([ + ]), + }), + }) +# --- diff --git a/tests/components/overkiz/test_diagnostics.py b/tests/components/overkiz/test_diagnostics.py new file mode 100644 index 00000000000..6d0498c237b --- /dev/null +++ b/tests/components/overkiz/test_diagnostics.py @@ -0,0 +1,63 @@ +"""Tests for the diagnostics data provided by the Overkiz integration.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry, load_json_object_fixture +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + diagnostic_data = load_json_object_fixture("overkiz/setup_tahoma_switch.json") + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + get_diagnostic_data=AsyncMock(return_value=diagnostic_data), + get_execution_history=AsyncMock(return_value=[]), + ): + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) + + +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device diagnostics.""" + diagnostic_data = load_json_object_fixture("overkiz/setup_tahoma_switch.json") + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "rts://****-****-6867/16756006")} + ) + assert device is not None + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + get_diagnostic_data=AsyncMock(return_value=diagnostic_data), + get_execution_history=AsyncMock(return_value=[]), + ): + assert ( + await get_diagnostics_for_device( + hass, hass_client, init_integration, device + ) + == snapshot + ) From 369cea175a8f16faae74c01b866b95b975b3405e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 7 Nov 2023 08:50:05 +0100 Subject: [PATCH 280/982] Update aioairzone-cloud to v0.3.6 (#103535) --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 175 +++++++++++++++--- .../airzone_cloud/test_binary_sensor.py | 8 + .../components/airzone_cloud/test_climate.py | 26 ++- .../airzone_cloud/test_diagnostics.py | 28 ++- tests/components/airzone_cloud/test_sensor.py | 3 + tests/components/airzone_cloud/util.py | 79 +++++++- 9 files changed, 290 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index ea22487f4a2..ab8e08835a3 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.5"] + "requirements": ["aioairzone-cloud==0.3.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9b8cdb0640f..09501cf8471 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.5 +aioairzone-cloud==0.3.6 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09c457191e2..936fe51a00d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.5 +aioairzone-cloud==0.3.6 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 1d1d060e80a..594a5e6765a 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -5,10 +5,18 @@ 'devices-config': dict({ 'device1': dict({ }), + 'device2': dict({ + }), + 'device3': dict({ + }), }), 'devices-status': dict({ 'device1': dict({ }), + 'device2': dict({ + }), + 'device3': dict({ + }), }), 'installations': dict({ 'installation1': dict({ @@ -22,11 +30,31 @@ ]), 'group_id': 'group1', }), + dict({ + 'devices': list([ + dict({ + 'device_id': 'device2', + 'ws_id': 'webserver2', + }), + ]), + 'group_id': 'group2', + }), + dict({ + 'devices': list([ + dict({ + 'device_id': 'device3', + 'ws_id': 'webserver3', + }), + ]), + 'group_id': 'group3', + }), ]), 'plugins': dict({ 'schedules': dict({ 'calendar_ws_ids': list([ 'webserver1', + 'webserver2', + 'webserver3', ]), }), }), @@ -50,6 +78,10 @@ 'webservers': dict({ 'webserver1': dict({ }), + 'webserver2': dict({ + }), + 'webserver3': dict({ + }), }), }), 'config_entry': dict({ @@ -90,6 +122,13 @@ 'name': 'Bron', 'power': False, 'problems': False, + 'speed': 6, + 'speed-type': 0, + 'speeds': dict({ + '1': 2, + '2': 4, + '3': 6, + }), 'temperature': 21.0, 'temperature-setpoint': 22.0, 'temperature-setpoint-cool-air': 22.0, @@ -103,7 +142,51 @@ 'temperature-setpoint-min-cool-air': 18.0, 'temperature-setpoint-min-hot-air': 16.0, 'temperature-step': 0.5, - 'web-server': '11:22:33:44:55:67', + 'web-server': 'webserver2', + 'ws-connected': True, + }), + 'aidoo_pro': dict({ + 'action': 1, + 'active': True, + 'available': True, + 'id': 'aidoo_pro', + 'installation': 'installation1', + 'is-connected': True, + 'mode': 2, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), + 'name': 'Bron Pro', + 'power': True, + 'problems': False, + 'speed': 3, + 'speed-type': 0, + 'speeds': dict({ + '0': 0, + '1': 1, + '2': 2, + '3': 3, + '4': 4, + '5': 5, + }), + 'temperature': 20.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-cool-air': 22.0, + 'temperature-setpoint-hot-air': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-auto-air': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-auto-air': 18.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-hot-air': 16.0, + 'temperature-step': 0.5, + 'web-server': 'webserver3', 'ws-connected': True, }), }), @@ -138,14 +221,14 @@ 'zone2', ]), }), - 'grp2': dict({ + 'group2': dict({ 'action': 6, 'active': False, 'aidoos': list([ 'aidoo1', ]), 'available': True, - 'id': 'grp2', + 'id': 'group2', 'installation': 'installation1', 'mode': 3, 'modes': list([ @@ -164,6 +247,32 @@ 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, }), + 'group3': dict({ + 'action': 1, + 'active': True, + 'aidoos': list([ + 'aidoo_pro', + ]), + 'available': True, + 'id': 'group3', + 'installation': 'installation1', + 'mode': 2, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), + 'name': 'Aidoo Pro Group', + 'num-devices': 1, + 'power': True, + 'temperature': 20.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-step': 0.5, + }), }), 'installations': dict({ 'installation1': dict({ @@ -171,11 +280,13 @@ 'active': True, 'aidoos': list([ 'aidoo1', + 'aidoo_pro', ]), 'available': True, 'groups': list([ 'group1', - 'grp2', + 'group2', + 'group3', ]), 'humidity': 27, 'id': 'installation1', @@ -188,20 +299,21 @@ 5, ]), 'name': 'House', - 'num-devices': 3, - 'num-groups': 2, + 'num-devices': 4, + 'num-groups': 3, 'power': True, 'systems': list([ 'system1', ]), - 'temperature': 22.0, - 'temperature-setpoint': 23.3, + 'temperature': 21.5, + 'temperature-setpoint': 23.0, 'temperature-setpoint-max': 30.0, 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, 'web-servers': list([ 'webserver1', - '11:22:33:44:55:67', + 'webserver2', + 'webserver3', ]), 'zones': list([ 'zone1', @@ -235,21 +347,6 @@ }), }), 'web-servers': dict({ - '11:22:33:44:55:67': dict({ - 'available': True, - 'connection-date': '2023-05-24 17:00:52 +0200', - 'disconnection-date': '2023-05-24 17:00:25 +0200', - 'firmware': '3.13', - 'id': '11:22:33:44:55:67', - 'installation': 'installation1', - 'name': 'WebServer 11:22:33:44:55:67', - 'type': 'ws_aidoo', - 'wifi-channel': 1, - 'wifi-mac': '**REDACTED**', - 'wifi-quality': 4, - 'wifi-rssi': -77, - 'wifi-ssid': 'Wifi', - }), 'webserver1': dict({ 'available': True, 'connection-date': '2023-05-07T12:55:51.000Z', @@ -265,6 +362,36 @@ 'wifi-rssi': -56, 'wifi-ssid': 'Wifi', }), + 'webserver2': dict({ + 'available': True, + 'connection-date': '2023-05-24 17:00:52 +0200', + 'disconnection-date': '2023-05-24 17:00:25 +0200', + 'firmware': '3.13', + 'id': 'webserver2', + 'installation': 'installation1', + 'name': 'WebServer 11:22:33:44:55:67', + 'type': 'ws_aidoo', + 'wifi-channel': 1, + 'wifi-mac': '**REDACTED**', + 'wifi-quality': 4, + 'wifi-rssi': -77, + 'wifi-ssid': 'Wifi', + }), + 'webserver3': dict({ + 'available': True, + 'connection-date': '2023-11-05 17:00:52 +0200', + 'disconnection-date': '2023-11-05 17:00:25 +0200', + 'firmware': '4.01', + 'id': 'webserver3', + 'installation': 'installation1', + 'name': 'WebServer 11:22:33:44:55:68', + 'type': 'ws_aidoo', + 'wifi-channel': 6, + 'wifi-mac': '**REDACTED**', + 'wifi-quality': 4, + 'wifi-rssi': -67, + 'wifi-ssid': 'Wifi', + }), }), 'zones': dict({ 'zone1': dict({ diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index a1b5d5319c0..ca40a732046 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -22,6 +22,14 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.bron_running") assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.bron_pro_problem") + assert state.state == STATE_OFF + assert state.attributes.get("errors") is None + assert state.attributes.get("warnings") is None + + state = hass.states.get("binary_sensor.bron_pro_running") + assert state.state == STATE_ON + # Systems state = hass.states.get("binary_sensor.system_1_problem") assert state.state == STATE_ON diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index 010c0d51072..7c273dc8bc2 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -40,7 +40,7 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: # Aidoos state = hass.states.get("climate.bron") assert state.state == HVACMode.OFF - assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None + assert ATTR_CURRENT_HUMIDITY not in state.attributes assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.0 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF assert state.attributes[ATTR_HVAC_MODES] == [ @@ -56,6 +56,24 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP assert state.attributes[ATTR_TEMPERATURE] == 22.0 + state = hass.states.get("climate.bron_pro") + assert state.state == HVACMode.COOL + assert ATTR_CURRENT_HUMIDITY not in state.attributes + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + # Groups state = hass.states.get("climate.group") assert state.state == HVACMode.COOL @@ -78,7 +96,7 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: state = hass.states.get("climate.house") assert state.state == HVACMode.COOL assert state.attributes[ATTR_CURRENT_HUMIDITY] == 27 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.5 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.HEAT_COOL, @@ -91,7 +109,7 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes[ATTR_MAX_TEMP] == 30 assert state.attributes[ATTR_MIN_TEMP] == 15 assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP - assert state.attributes[ATTR_TEMPERATURE] == 23.3 + assert state.attributes[ATTR_TEMPERATURE] == 23.0 # Zones state = hass.states.get("climate.dormitorio") @@ -541,7 +559,7 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.house") - assert state.attributes[ATTR_TEMPERATURE] == 23.3 + assert state.attributes[ATTR_TEMPERATURE] == 23.0 # Zones with patch( diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index 8bef70501e7..2b2e3f33105 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -20,7 +20,7 @@ from homeassistant.components.airzone_cloud.const import DOMAIN from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from .util import CONFIG, WS_ID, async_init_integration +from .util import CONFIG, WS_ID, WS_ID_AIDOO, WS_ID_AIDOO_PRO, async_init_integration from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -28,9 +28,13 @@ from tests.typing import ClientSessionGenerator RAW_DATA_MOCK = { RAW_DEVICES_CONFIG: { "dev1": {}, + "dev2": {}, + "dev3": {}, }, RAW_DEVICES_STATUS: { "dev1": {}, + "dev2": {}, + "dev3": {}, }, RAW_INSTALLATIONS: { CONFIG[CONF_ID]: { @@ -44,11 +48,31 @@ RAW_DATA_MOCK = { }, ], }, + { + API_GROUP_ID: "grp2", + API_DEVICES: [ + { + API_DEVICE_ID: "dev2", + API_WS_ID: WS_ID_AIDOO, + }, + ], + }, + { + API_GROUP_ID: "grp3", + API_DEVICES: [ + { + API_DEVICE_ID: "dev3", + API_WS_ID: WS_ID_AIDOO_PRO, + }, + ], + }, ], "plugins": { "schedules": { "calendar_ws_ids": [ WS_ID, + WS_ID_AIDOO, + WS_ID_AIDOO_PRO, ], }, }, @@ -57,6 +81,8 @@ RAW_DATA_MOCK = { RAW_INSTALLATIONS_LIST: {}, RAW_WEBSERVERS: { WS_ID: {}, + WS_ID_AIDOO: {}, + WS_ID_AIDOO_PRO: {}, }, "test_cov": { "1": None, diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index d9b19f93f7d..b370e75c9aa 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -16,6 +16,9 @@ async def test_airzone_create_sensors( state = hass.states.get("sensor.bron_temperature") assert state.state == "21.0" + state = hass.states.get("sensor.bron_pro_temperature") + assert state.state == "20.0" + # WebServers state = hass.states.get("sensor.webserver_11_22_33_44_55_66_signal_strength") assert state.state == "-56" diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 76349d06481..6924344a092 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -7,6 +7,7 @@ from aioairzone_cloud.common import OperationMode from aioairzone_cloud.const import ( API_ACTIVE, API_AZ_AIDOO, + API_AZ_AIDOO_PRO, API_AZ_SYSTEM, API_AZ_ZONE, API_CELSIUS, @@ -52,6 +53,9 @@ from aioairzone_cloud.const import ( API_SP_AIR_HEAT, API_SP_AIR_STOP, API_SP_AIR_VENT, + API_SPEED_CONF, + API_SPEED_TYPE, + API_SPEED_VALUES, API_STAT_AP_MAC, API_STAT_CHANNEL, API_STAT_QUALITY, @@ -79,6 +83,7 @@ from tests.common import MockConfigEntry WS_ID = "11:22:33:44:55:66" WS_ID_AIDOO = "11:22:33:44:55:67" +WS_ID_AIDOO_PRO = "11:22:33:44:55:68" CONFIG = { CONF_ID: "inst1", @@ -136,6 +141,18 @@ GET_INSTALLATION_MOCK = { }, ], }, + { + API_GROUP_ID: "grp3", + API_NAME: "Aidoo Pro Group", + API_DEVICES: [ + { + API_DEVICE_ID: "aidoo_pro", + API_NAME: "Bron Pro", + API_TYPE: API_AZ_AIDOO_PRO, + API_WS_ID: WS_ID_AIDOO_PRO, + }, + ], + }, ], } @@ -147,6 +164,7 @@ GET_INSTALLATIONS_MOCK = { API_WS_IDS: [ WS_ID, WS_ID_AIDOO, + WS_ID_AIDOO_PRO, ], }, ], @@ -186,6 +204,23 @@ GET_WEBSERVER_MOCK_AIDOO = { }, } +GET_WEBSERVER_MOCK_AIDOO_PRO = { + API_WS_TYPE: "ws_aidoo", + API_CONFIG: { + API_WS_FW: "4.01", + API_STAT_SSID: "Wifi", + API_STAT_CHANNEL: 6, + API_STAT_AP_MAC: "00:00:00:00:00:02", + }, + API_STATUS: { + API_IS_CONNECTED: True, + API_STAT_QUALITY: 4, + API_STAT_RSSI: -67, + API_CONNECTION_DATE: "2023-11-05 17:00:52 +0200", + API_DISCONNECTION_DATE: "2023-11-05 17:00:25 +0200", + }, +} + def mock_get_device_status(device: Device) -> dict[str, Any]: """Mock API device status.""" @@ -214,11 +249,46 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, API_RANGE_SP_MIN_HOT_AIR: {API_CELSIUS: 16, API_FAH: 61}, API_POWER: False, + API_SPEED_CONF: 6, + API_SPEED_VALUES: [2, 4, 6], + API_SPEED_TYPE: 0, API_IS_CONNECTED: True, API_WS_CONNECTED: True, API_LOCAL_TEMP: {API_CELSIUS: 21, API_FAH: 70}, API_WARNINGS: [], } + if device.get_id() == "aidoo_pro": + return { + API_ACTIVE: True, + API_ERRORS: [], + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [ + OperationMode.AUTO.value, + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], + API_SP_AIR_AUTO: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_COOL: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_HEAT: {API_CELSIUS: 22, API_FAH: 72}, + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_AUTO_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_AUTO_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_HOT_AIR: {API_CELSIUS: 16, API_FAH: 61}, + API_POWER: True, + API_SPEED_CONF: 3, + API_SPEED_VALUES: [0, 1, 2, 3, 4, 5], + API_SPEED_TYPE: 0, + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + API_LOCAL_TEMP: {API_CELSIUS: 20, API_FAH: 68}, + API_WARNINGS: [], + } if device.get_id() == "system1": return { API_ERRORS: [ @@ -304,16 +374,19 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } - return None + return {} def mock_get_webserver(webserver: WebServer, devices: bool) -> dict[str, Any]: """Mock API get webserver.""" + if webserver.get_id() == WS_ID: + return GET_WEBSERVER_MOCK if webserver.get_id() == WS_ID_AIDOO: return GET_WEBSERVER_MOCK_AIDOO - - return GET_WEBSERVER_MOCK + if webserver.get_id() == WS_ID_AIDOO_PRO: + return GET_WEBSERVER_MOCK_AIDOO_PRO + return {} async def async_init_integration( From b6a3f628d13eb040957d9ce3c4ea67ce22cc58c8 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 7 Nov 2023 10:04:59 +0200 Subject: [PATCH 281/982] Bump transmission-rpc to version 7.0.3 (#103502) * Bump transmission-rpc to version 7.0.3 * Change `date_added` to `added_date` --- homeassistant/components/transmission/const.py | 4 ++-- homeassistant/components/transmission/coordinator.py | 4 ++-- homeassistant/components/transmission/manifest.json | 2 +- homeassistant/components/transmission/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 77d2baf7213..6074d03acf6 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -16,9 +16,9 @@ ORDER_WORST_RATIO_FIRST = "worst_ratio_first" SUPPORTED_ORDER_MODES: dict[str, Callable[[list[Torrent]], list[Torrent]]] = { ORDER_NEWEST_FIRST: lambda torrents: sorted( - torrents, key=lambda t: t.date_added, reverse=True + torrents, key=lambda t: t.added_date, reverse=True ), - ORDER_OLDEST_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.date_added), + ORDER_OLDEST_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.added_date), ORDER_WORST_RATIO_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.ratio), ORDER_BEST_RATIO_FIRST: lambda torrents: sorted( torrents, key=lambda t: t.ratio, reverse=True diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 91597d0e43d..d03ef5e37fb 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -146,7 +146,7 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): """Stop all active torrents.""" if not self.torrents: return - torrent_ids = [torrent.id for torrent in self.torrents] + torrent_ids: list[int | str] = [torrent.id for torrent in self.torrents] self.api.stop_torrent(torrent_ids) def set_alt_speed_enabled(self, is_enabled: bool) -> None: @@ -158,4 +158,4 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): if self._session is None: return None - return self._session.alt_speed_enabled # type: ignore[no-any-return] + return self._session.alt_speed_enabled diff --git a/homeassistant/components/transmission/manifest.json b/homeassistant/components/transmission/manifest.json index 17b3bbbf49b..ad89ae94033 100644 --- a/homeassistant/components/transmission/manifest.json +++ b/homeassistant/components/transmission/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/transmission", "iot_class": "local_polling", "loggers": ["transmissionrpc"], - "requirements": ["transmission-rpc==4.1.5"] + "requirements": ["transmission-rpc==7.0.3"] } diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index c3ba418f885..d52a98a430e 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -206,7 +206,7 @@ def _torrents_info( torrents = SUPPORTED_ORDER_MODES[order](torrents) for torrent in torrents[:limit]: info = infos[torrent.name] = { - "added_date": torrent.date_added, + "added_date": torrent.added_date, "percent_done": f"{torrent.percent_done * 100:.2f}", "status": torrent.status, "id": torrent.id, diff --git a/requirements_all.txt b/requirements_all.txt index 09501cf8471..022dc2dcc80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2614,7 +2614,7 @@ tp-connected==0.0.4 tplink-omada-client==1.3.2 # homeassistant.components.transmission -transmission-rpc==4.1.5 +transmission-rpc==7.0.3 # homeassistant.components.twinkly ttls==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 936fe51a00d..acc4755f67f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ total-connect-client==2023.2 tplink-omada-client==1.3.2 # homeassistant.components.transmission -transmission-rpc==4.1.5 +transmission-rpc==7.0.3 # homeassistant.components.twinkly ttls==1.5.1 From 0a05a16fcb13d576829a1aec50b5f3d6c65c870c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Nov 2023 00:11:52 -0800 Subject: [PATCH 282/982] Add read-only Caldav todo platform (#103415) * Add Caldav todo enttiy for VTODO components * Use new shared apis for todos * Update homeassistant/components/caldav/todo.py Co-authored-by: Martin Hjelmare * Update todo item conversion checks * Iterate over results once * Add 15 minute poll interval for caldav todo --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/caldav/__init__.py | 2 +- homeassistant/components/caldav/api.py | 7 + .../components/caldav/coordinator.py | 21 +-- homeassistant/components/caldav/todo.py | 94 +++++++++++ tests/components/caldav/test_calendar.py | 2 +- tests/components/caldav/test_todo.py | 146 ++++++++++++++++++ 6 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/caldav/todo.py create mode 100644 tests/components/caldav/test_todo.py diff --git a/homeassistant/components/caldav/__init__.py b/homeassistant/components/caldav/__init__.py index d62ff3eb5ce..eed06a3a005 100644 --- a/homeassistant/components/caldav/__init__.py +++ b/homeassistant/components/caldav/__init__.py @@ -22,7 +22,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.CALENDAR] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index b818e61dd2b..f9236049048 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -23,3 +23,10 @@ async def async_get_calendars( for calendar, supported_components in zip(calendars, components_results) if component in supported_components ] + + +def get_attr_value(obj: caldav.CalendarObjectResource, attribute: str) -> str | None: + """Return the value of the CalDav object attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index ee34a56e23b..380471284de 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util +from .api import get_attr_value + _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -59,11 +61,11 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): continue event_list.append( CalendarEvent( - summary=self.get_attr_value(vevent, "summary") or "", + summary=get_attr_value(vevent, "summary") or "", start=self.to_local(vevent.dtstart.value), end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), + location=get_attr_value(vevent, "location"), + description=get_attr_value(vevent, "description"), ) ) @@ -150,15 +152,15 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): # Populate the entity attributes with the event values (summary, offset) = extract_offset( - self.get_attr_value(vevent, "summary") or "", OFFSET + get_attr_value(vevent, "summary") or "", OFFSET ) self.offset = offset return CalendarEvent( summary=summary, start=self.to_local(vevent.dtstart.value), end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), + location=get_attr_value(vevent, "location"), + description=get_attr_value(vevent, "description"), ) @staticmethod @@ -208,13 +210,6 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): return dt_util.as_local(obj) return obj - @staticmethod - def get_attr_value(obj, attribute): - """Return the value of the attribute if defined.""" - if hasattr(obj, attribute): - return getattr(obj, attribute).value - return None - @staticmethod def get_end_date(obj): """Return the end datetime as determined by dtend or duration.""" diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py new file mode 100644 index 00000000000..887f760399b --- /dev/null +++ b/homeassistant/components/caldav/todo.py @@ -0,0 +1,94 @@ +"""CalDAV todo platform.""" +from __future__ import annotations + +from datetime import timedelta +from functools import partial +import logging + +import caldav + +from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .api import async_get_calendars, get_attr_value +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=15) + +SUPPORTED_COMPONENT = "VTODO" +TODO_STATUS_MAP = { + "NEEDS-ACTION": TodoItemStatus.NEEDS_ACTION, + "IN-PROCESS": TodoItemStatus.NEEDS_ACTION, + "COMPLETED": TodoItemStatus.COMPLETED, + "CANCELLED": TodoItemStatus.COMPLETED, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the CalDav todo platform for a config entry.""" + client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id] + calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) + async_add_entities( + ( + WebDavTodoListEntity( + calendar, + entry.entry_id, + ) + for calendar in calendars + ), + True, + ) + + +def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: + """Convert a caldav Todo into a TodoItem.""" + if ( + not hasattr(resource.instance, "vtodo") + or not (todo := resource.instance.vtodo) + or (uid := get_attr_value(todo, "uid")) is None + or (summary := get_attr_value(todo, "summary")) is None + ): + return None + return TodoItem( + uid=uid, + summary=summary, + status=TODO_STATUS_MAP.get( + get_attr_value(todo, "status") or "", + TodoItemStatus.NEEDS_ACTION, + ), + ) + + +class WebDavTodoListEntity(TodoListEntity): + """CalDAV To-do list entity.""" + + _attr_has_entity_name = True + + def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: + """Initialize WebDavTodoListEntity.""" + self._calendar = calendar + self._attr_name = (calendar.name or "Unknown").capitalize() + self._attr_unique_id = f"{config_entry_id}-{calendar.id}" + + async def async_update(self) -> None: + """Update To-do list entity state.""" + results = await self.hass.async_add_executor_job( + partial( + self._calendar.search, + todo=True, + include_completed=True, + ) + ) + self._attr_todo_items = [ + todo_item + for resource in results + if (todo_item := _todo_item(resource)) is not None + ] diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 8a947747ab9..5a648949f0f 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -365,7 +365,7 @@ def _mock_calendar(name: str, supported_components: list[str] | None = None) -> calendar = Mock() events = [] for idx, event in enumerate(EVENTS): - events.append(Event(None, "%d.ics" % idx, event, calendar, str(idx))) + events.append(Event(None, f"{idx}.ics", event, calendar, str(idx))) if supported_components is None: supported_components = ["VEVENT"] calendar.search = MagicMock(return_value=events) diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py new file mode 100644 index 00000000000..16a95d418a8 --- /dev/null +++ b/tests/components/caldav/test_todo.py @@ -0,0 +1,146 @@ +"""The tests for the webdav todo component.""" +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock, Mock + +from caldav.objects import Todo +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +CALENDAR_NAME = "My Tasks" +ENTITY_NAME = "My tasks" +TEST_ENTITY = "todo.my_tasks" + +TODO_NO_STATUS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:1 +DTSTAMP:20231125T000000Z +SUMMARY:Milk +END:VTODO +END:VCALENDAR""" + +TODO_NEEDS_ACTION = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:2 +DTSTAMP:20171125T000000Z +SUMMARY:Cheese +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + +TODO_COMPLETED = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:3 +DTSTAMP:20231125T000000Z +SUMMARY:Wine +STATUS:COMPLETED +END:VTODO +END:VCALENDAR""" + + +TODO_NO_SUMMARY = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:4 +DTSTAMP:20171126T000000Z +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set up config entry platforms.""" + return [Platform.TODO] + + +@pytest.fixture(name="todos") +def mock_todos() -> list[str]: + """Fixture to return VTODO objects for the calendar.""" + return [] + + +@pytest.fixture(name="supported_components") +def mock_supported_components() -> list[str]: + """Fixture to set supported components of the calendar.""" + return ["VTODO"] + + +@pytest.fixture(name="calendars") +def mock_calendars(todos: list[str], supported_components: list[str]) -> list[Mock]: + """Fixture to create calendars for the test.""" + calendar = Mock() + items = [ + Todo(None, f"{idx}.ics", item, calendar, str(idx)) + for idx, item in enumerate(todos) + ] + calendar.search = MagicMock(return_value=items) + calendar.name = CALENDAR_NAME + calendar.get_supported_components = MagicMock(return_value=supported_components) + return [calendar] + + +@pytest.mark.parametrize( + ("todos", "expected_state"), + [ + ([], "0"), + ( + [TODO_NEEDS_ACTION], + "1", + ), + ( + [TODO_NO_STATUS], + "1", + ), + ([TODO_COMPLETED], "0"), + ([TODO_NO_STATUS, TODO_NEEDS_ACTION, TODO_COMPLETED], "2"), + ([TODO_NO_SUMMARY], "0"), + ], + ids=( + "empty", + "needs_action", + "no_status", + "completed", + "all", + "no_summary", + ), +) +async def test_todo_list_state( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + expected_state: str, +) -> None: + """Test a calendar entity from a config entry.""" + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == ENTITY_NAME + assert state.state == expected_state + assert dict(state.attributes) == { + "friendly_name": ENTITY_NAME, + } + + +@pytest.mark.parametrize( + ("supported_components", "has_entity"), + [([], False), (["VTODO"], True), (["VEVENT"], False), (["VEVENT", "VTODO"], True)], +) +async def test_supported_components( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + has_entity: bool, +) -> None: + """Test a calendar supported components matches VTODO.""" + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert (state is not None) == has_entity From 446de10aecd030938f7a656b09140b9bf82ac1b1 Mon Sep 17 00:00:00 2001 From: Florent Fourcot Date: Tue, 7 Nov 2023 10:14:50 +0100 Subject: [PATCH 283/982] Add hvac_action support to melcloud (#103372) Since actions are shared between water tank (if any) and climate device, hvac action can be idle even when heat pump is active Co-authored-by: jan iversen --- homeassistant/components/melcloud/climate.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 589223dc0f3..9d2a4f08257 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -20,6 +20,7 @@ from homeassistant.components.climate import ( DEFAULT_MIN_TEMP, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry @@ -60,6 +61,18 @@ ATW_ZONE_HVAC_MODE_LOOKUP = { } ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATW_ZONE_HVAC_MODE_LOOKUP.items()} +ATW_ZONE_HVAC_ACTION_LOOKUP = { + atw.STATUS_IDLE: HVACAction.IDLE, + atw.STATUS_HEAT_ZONES: HVACAction.HEATING, + atw.STATUS_COOL: HVACAction.COOLING, + atw.STATUS_STANDBY: HVACAction.IDLE, + # Heating water tank, so the zone is idle + atw.STATUS_HEAT_WATER: HVACAction.IDLE, + atw.STATUS_LEGIONELLA: HVACAction.IDLE, + # Heat pump cannot heat in this mode, but will be ready soon + atw.STATUS_DEFROST: HVACAction.PREHEATING, +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -351,6 +364,13 @@ class AtwDeviceZoneClimate(MelCloudClimate): """Return the list of available hvac operation modes.""" return [self.hvac_mode] + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running hvac operation.""" + if not self._device.power: + return HVACAction.OFF + return ATW_ZONE_HVAC_ACTION_LOOKUP.get(self._device.status) + @property def current_temperature(self) -> float | None: """Return the current temperature.""" From 3ca6cddc1f344687659b570570e443bd2a57a5da Mon Sep 17 00:00:00 2001 From: dupondje Date: Tue, 7 Nov 2023 10:38:37 +0100 Subject: [PATCH 284/982] Update dsmr-parser to 1.3.1 to fix parsing issues (#103572) --- homeassistant/components/dsmr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index b3f59a15b80..90fd2d6cdce 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.3.0"] + "requirements": ["dsmr-parser==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 022dc2dcc80..5c387adb361 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -704,7 +704,7 @@ dovado==0.4.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr-parser==1.3.0 +dsmr-parser==1.3.1 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index acc4755f67f..9a34386d158 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -575,7 +575,7 @@ discovery30303==0.2.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr-parser==1.3.0 +dsmr-parser==1.3.1 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 From da1780f9ec5916311fd8d4dfc54a806de4418f38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Nov 2023 03:48:34 -0600 Subject: [PATCH 285/982] Small cleanups to process_success_login (#103282) --- homeassistant/components/auth/login_flow.py | 2 +- homeassistant/components/http/ban.py | 19 ++++++++++--------- .../components/websocket_api/auth.py | 2 +- tests/components/http/test_ban.py | 16 +++++++++++++++- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 364f5242377..e0cc0eeb1ec 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -235,7 +235,7 @@ class LoginFlowBaseView(HomeAssistantView): f"Login blocked: {user_access_error}", HTTPStatus.FORBIDDEN ) - await process_success_login(request) + process_success_login(request) result["result"] = self._store_result(client_id, result_obj) return self.json(result) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 85feb19a24b..89d927ee8af 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -162,27 +162,28 @@ async def process_wrong_login(request: Request) -> None: ) -async def process_success_login(request: Request) -> None: +@callback +def process_success_login(request: Request) -> None: """Process a success login attempt. Reset failed login attempts counter for remote IP address. No release IP address from banned list function, it can only be done by manual modify ip bans config file. """ - remote_addr = ip_address(request.remote) # type: ignore[arg-type] - + app = request.app # Check if ban middleware is loaded - if KEY_BAN_MANAGER not in request.app or request.app[KEY_LOGIN_THRESHOLD] < 1: + if KEY_BAN_MANAGER not in app or app[KEY_LOGIN_THRESHOLD] < 1: return - if ( - remote_addr in request.app[KEY_FAILED_LOGIN_ATTEMPTS] - and request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > 0 - ): + remote_addr = ip_address(request.remote) # type: ignore[arg-type] + login_attempt_history: defaultdict[IPv4Address | IPv6Address, int] = app[ + KEY_FAILED_LOGIN_ATTEMPTS + ] + if remote_addr in login_attempt_history and login_attempt_history[remote_addr] > 0: _LOGGER.debug( "Login success, reset failed login attempts counter from %s", remote_addr ) - request.app[KEY_FAILED_LOGIN_ATTEMPTS].pop(remote_addr) + login_attempt_history.pop(remote_addr) class IpBan: diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 9f8e8bfb6f8..2c86a26efc9 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -103,7 +103,7 @@ class AuthPhase: ) -> ActiveConnection: """Create an active connection.""" self._logger.debug("Auth OK") - await process_success_login(self._request) + process_success_login(self._request) self._send_message(auth_ok_message()) return ActiveConnection( self._logger, self._hass, self._send_message, user, refresh_token diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index e6e237a7b67..8082a268a80 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -16,6 +16,7 @@ from homeassistant.components.http.ban import ( KEY_BAN_MANAGER, KEY_FAILED_LOGIN_ATTEMPTS, IpBanManager, + process_success_login, setup_bans, ) from homeassistant.components.http.view import request_handler_factory @@ -332,9 +333,14 @@ async def test_failed_login_attempts_counter( """Return 200 status code.""" return None, 200 + async def auth_true_handler(request): + """Return 200 status code.""" + process_success_login(request) + return None, 200 + app.router.add_get( "/auth_true", - request_handler_factory(hass, Mock(requires_auth=True), auth_handler), + request_handler_factory(hass, Mock(requires_auth=True), auth_true_handler), ) app.router.add_get( "/auth_false", @@ -377,4 +383,12 @@ async def test_failed_login_attempts_counter( # We no longer support trusted networks. resp = await client.get("/auth_true") assert resp.status == HTTPStatus.OK + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 0 + + resp = await client.get("/auth_false") + assert resp.status == HTTPStatus.UNAUTHORIZED + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 1 + + resp = await client.get("/auth_false") + assert resp.status == HTTPStatus.UNAUTHORIZED assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 From ef7a3787bb5c0ab3cb518a4981a81c9f1ba2300d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 7 Nov 2023 10:51:11 +0100 Subject: [PATCH 286/982] Remove Ezviz detection sensitivity service (#103392) --- homeassistant/components/ezviz/camera.py | 33 --------------------- homeassistant/components/ezviz/strings.json | 11 ------- 2 files changed, 44 deletions(-) diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 85b1f316a7b..e42968603e4 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -33,7 +33,6 @@ from .const import ( ATTR_LEVEL, ATTR_SERIAL, ATTR_SPEED, - ATTR_TYPE, CONF_FFMPEG_ARGUMENTS, DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, @@ -45,7 +44,6 @@ from .const import ( DOMAIN, SERVICE_ALARM_SOUND, SERVICE_ALARM_TRIGGER, - SERVICE_DETECTION_SENSITIVITY, SERVICE_PTZ, SERVICE_WAKE_DEVICE, ) @@ -157,15 +155,6 @@ async def async_setup_entry( "perform_alarm_sound", ) - platform.async_register_entity_service( - SERVICE_DETECTION_SENSITIVITY, - { - vol.Required(ATTR_LEVEL): cv.positive_int, - vol.Required(ATTR_TYPE): cv.positive_int, - }, - "perform_set_alarm_detection_sensibility", - ) - class EzvizCamera(EzvizEntity, Camera): """An implementation of a EZVIZ security camera.""" @@ -329,25 +318,3 @@ class EzvizCamera(EzvizEntity, Camera): raise HTTPError( "Cannot set alarm sound level for on movement detected" ) from err - - def perform_set_alarm_detection_sensibility( - self, level: int, type_value: int - ) -> None: - """Set camera detection sensibility level service.""" - try: - self.coordinator.ezviz_client.detection_sensibility( - self._serial, level, type_value - ) - except (HTTPError, PyEzvizError) as err: - raise PyEzvizError("Cannot set detection sensitivity level") from err - - ir.async_create_issue( - self.hass, - DOMAIN, - "service_depreciation_detection_sensibility", - breaks_in_ha_version="2023.12.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_depreciation_detection_sensibility", - ) diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 11144f8ae71..11ec31fee4a 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -60,17 +60,6 @@ } }, "issues": { - "service_depreciation_detection_sensibility": { - "title": "Ezviz Detection sensitivity service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ezviz::issues::service_depreciation_detection_sensibility::title%]", - "description": "The Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.12.\nTo set the sensitivity, you can instead use the `number.set_value` service targetting the Detection sensitivity entity.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." - } - } - } - }, "service_deprecation_alarm_sound_level": { "title": "Ezviz Alarm sound level service is being removed", "fix_flow": { From 2a80164508984ec8f9da2ab833da1e8451f08516 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Nov 2023 04:22:41 -0600 Subject: [PATCH 287/982] Bump aioesphomeapi to 18.2.4 (#103552) --- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 25 +++++++++++++++++-- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4e8d3c8dde4..cb1a741c447 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.2.1", + "aioesphomeapi==18.2.4", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5c387adb361..5b9271294e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.1 +aioesphomeapi==18.2.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a34386d158..ba028f8246b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.1 +aioesphomeapi==18.2.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 4ff6b503b3c..48b0868e406 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -77,6 +77,17 @@ def mock_config_entry(hass) -> MockConfigEntry: return config_entry +class BaseMockReconnectLogic(ReconnectLogic): + """Mock ReconnectLogic.""" + + def stop_callback(self) -> None: + """Stop the reconnect logic.""" + # For the purposes of testing, we don't want to wait + # for the reconnect logic to finish trying to connect + self._cancel_connect("forced disconnect from test") + self._is_stopped = True + + @pytest.fixture def mock_device_info() -> DeviceInfo: """Return the default mocked device info.""" @@ -132,7 +143,10 @@ def mock_client(mock_device_info) -> APIClient: mock_client.address = "127.0.0.1" mock_client.api_version = APIVersion(99, 99) - with patch("homeassistant.components.esphome.APIClient", mock_client), patch( + with patch( + "homeassistant.components.esphome.manager.ReconnectLogic", + BaseMockReconnectLogic, + ), patch("homeassistant.components.esphome.APIClient", mock_client), patch( "homeassistant.components.esphome.config_flow.APIClient", mock_client ): yield mock_client @@ -234,7 +248,7 @@ async def _mock_generic_device_entry( try_connect_done = Event() - class MockReconnectLogic(ReconnectLogic): + class MockReconnectLogic(BaseMockReconnectLogic): """Mock ReconnectLogic.""" def __init__(self, *args, **kwargs): @@ -250,6 +264,13 @@ async def _mock_generic_device_entry( try_connect_done.set() return result + def stop_callback(self) -> None: + """Stop the reconnect logic.""" + # For the purposes of testing, we don't want to wait + # for the reconnect logic to finish trying to connect + self._cancel_connect("forced disconnect from test") + self._is_stopped = True + with patch( "homeassistant.components.esphome.manager.ReconnectLogic", MockReconnectLogic ): From 2349e3ac1d1147781b9bd2fa1b529f0ea4f2a385 Mon Sep 17 00:00:00 2001 From: Christian Fetzer Date: Tue, 7 Nov 2023 12:15:25 +0100 Subject: [PATCH 288/982] Add select for partial position (garage door) in Overkiz (#99500) --- homeassistant/components/overkiz/select.py | 25 +++++++++++++++++++ homeassistant/components/overkiz/strings.json | 7 ++++++ 2 files changed, 32 insertions(+) diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 155fc3a538f..5f72ca23a80 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -42,6 +42,19 @@ def _select_option_open_closed_pedestrian( ) +def _select_option_open_closed_partial( + option: str, execute_command: Callable[..., Awaitable[None]] +) -> Awaitable[None]: + """Change the selected option for Open/Closed/Partial.""" + return execute_command( + { + OverkizCommandParam.CLOSED: OverkizCommand.CLOSE, + OverkizCommandParam.OPEN: OverkizCommand.OPEN, + OverkizCommandParam.PARTIAL: OverkizCommand.PARTIAL_POSITION, + }[OverkizCommandParam(option)] + ) + + def _select_option_memorized_simple_volume( option: str, execute_command: Callable[..., Awaitable[None]] ) -> Awaitable[None]: @@ -73,6 +86,18 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ select_option=_select_option_open_closed_pedestrian, translation_key="open_closed_pedestrian", ), + OverkizSelectDescription( + key=OverkizState.CORE_OPEN_CLOSED_PARTIAL, + name="Position", + icon="mdi:content-save-cog", + options=[ + OverkizCommandParam.OPEN, + OverkizCommandParam.PARTIAL, + OverkizCommandParam.CLOSED, + ], + select_option=_select_option_open_closed_partial, + translation_key="open_closed_partial", + ), OverkizSelectDescription( key=OverkizState.IO_MEMORIZED_SIMPLE_VOLUME, name="Memorized simple volume", diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index bcf1e121f6f..82d29a7534a 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -64,6 +64,13 @@ "closed": "[%key:common::state::closed%]" } }, + "open_closed_partial": { + "state": { + "open": "[%key:common::state::open%]", + "partial": "Partial", + "closed": "[%key:common::state::closed%]" + } + }, "memorized_simple_volume": { "state": { "highest": "Highest", From b233d248ff69332857f3bfce912dd1aae1cb5157 Mon Sep 17 00:00:00 2001 From: Etienne G Date: Tue, 7 Nov 2023 22:48:41 +1100 Subject: [PATCH 289/982] Add support for SomfyHeatingTemperatureInterface in Overkiz integration (#83514) --- .../overkiz/climate_entities/__init__.py | 2 + .../somfy_heating_temperature_interface.py | 179 ++++++++++++++++++ homeassistant/components/overkiz/const.py | 1 + 3 files changed, 182 insertions(+) create mode 100644 homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index 9d54c04422a..b6345dd9b95 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -9,6 +9,7 @@ from .atlantic_electrical_towel_dryer import AtlanticElectricalTowelDryer from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl +from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface @@ -21,6 +22,7 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, + UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } diff --git a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py new file mode 100644 index 00000000000..6c3ee3454ce --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py @@ -0,0 +1,179 @@ +"""Support for Somfy Heating Temperature Interface.""" +from __future__ import annotations + +from typing import Any + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +OVERKIZ_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.SECURED: PRESET_AWAY, + OverkizCommandParam.ECO: PRESET_ECO, + OverkizCommandParam.COMFORT: PRESET_COMFORT, + OverkizCommandParam.FREE: PRESET_NONE, +} + +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTO: HVACMode.AUTO, + OverkizCommandParam.MANU: HVACMode.HEAT_COOL, +} + +HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} + +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.COOLING: HVACAction.COOLING, + OverkizCommandParam.HEATING: HVACAction.HEATING, +} + +MAP_PRESET_TEMPERATURES: dict[str, str] = { + PRESET_COMFORT: OverkizState.CORE_COMFORT_ROOM_TEMPERATURE, + PRESET_ECO: OverkizState.CORE_ECO_ROOM_TEMPERATURE, + PRESET_AWAY: OverkizState.CORE_SECURED_POSITION_TEMPERATURE, +} + +SETPOINT_MODE_TO_OVERKIZ_COMMAND: dict[str, str] = { + OverkizCommandParam.COMFORT: OverkizCommand.SET_COMFORT_TEMPERATURE, + OverkizCommandParam.ECO: OverkizCommand.SET_ECO_TEMPERATURE, + OverkizCommandParam.SECURED: OverkizCommand.SET_SECURED_POSITION_TEMPERATURE, +} + +TEMPERATURE_SENSOR_DEVICE_INDEX = 2 + + +class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): + """Representation of Somfy Heating Temperature Interface. + + The thermostat has 3 ways of working: + - Auto: Switch to eco/comfort temperature on a schedule (day/hour of the day) + - Manual comfort: The thermostat use the temperature of the comfort setting (19°C degree by default) + - Manual eco: The thermostat use the temperature of the eco setting (17°C by default) + - Freeze protection: The thermostat use the temperature of the freeze protection (7°C by default) + + There's also the possibility to change the working mode, this can be used to change from a heated + floor to a cooling floor in the summer. + """ + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + # Both min and max temp values have been retrieved from the Somfy Application. + _attr_min_temp = 15.0 + _attr_max_temp = 26.0 + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation i.e. heat, cool mode.""" + state = self.device.states[OverkizState.CORE_ON_OFF] + if state and state.value_as_str == OverkizCommandParam.OFF: + return HVACMode.OFF + + if ( + state := self.device.states[ + OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_ACTIVE_MODE + ] + ) and state.value_as_str: + return OVERKIZ_TO_HVAC_MODES[state.value_as_str] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_ACTIVE_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[ + OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE + ] + ) and state.value_as_str: + return OVERKIZ_TO_PRESET_MODES[state.value_as_str] + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_MANU_AND_SET_POINT_MODES, + PRESET_MODES_TO_OVERKIZ[preset_mode], + ) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running hvac operation if supported.""" + if ( + current_operation := self.device.states[ + OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE + ] + ) and current_operation.value_as_str: + return OVERKIZ_TO_HVAC_ACTION[current_operation.value_as_str] + + return None + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + + # Allow to get the current target temperature for the current preset + # The preset can be switched manually or on a schedule (auto). + # This allows to reflect the current target temperature automatically + if not self.preset_mode: + return None + + mode = PRESET_MODES_TO_OVERKIZ[self.preset_mode] + if mode not in MAP_PRESET_TEMPERATURES: + return None + + if state := self.device.states[MAP_PRESET_TEMPERATURES[mode]]: + return state.value_as_float + return None + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return temperature.value_as_float + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + if ( + mode := self.device.states[ + OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE + ] + ) and mode.value_as_str: + return await self.executor.async_execute_command( + SETPOINT_MODE_TO_OVERKIZ_COMMAND[mode.value_as_str], temperature + ) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 102d09a76b1..91346b63ce0 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -98,6 +98,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.RTD_OUTDOOR_SIREN: Platform.SWITCH, # widgetName, uiClass is Siren (not supported) UIWidget.RTS_GENERIC: Platform.COVER, # widgetName, uiClass is Generic (not supported) UIWidget.SIREN_STATUS: None, # widgetName, uiClass is Siren (siren) + UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.SOMFY_THERMOSTAT: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, # widgetName, uiClass is Alarm (not supported) UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) From 76b322c6b378b0994d08887e1221159b7ed0e607 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Nov 2023 13:10:14 +0100 Subject: [PATCH 290/982] Retrieve manufacturer and configuration_url from client in Overkiz integration (#103585) Retrieve manufacturer and configuration_url from client --- homeassistant/components/overkiz/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 6ca082ace76..95fc2af8e06 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -129,10 +129,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config_entry_id=entry.entry_id, identifiers={(DOMAIN, gateway.id)}, model=gateway.sub_type.beautify_name if gateway.sub_type else None, - manufacturer=server.manufacturer, + manufacturer=client.server.manufacturer, name=gateway.type.beautify_name if gateway.type else gateway.id, sw_version=gateway.connectivity.protocol_version, - configuration_url=server.configuration_url, + configuration_url=client.server.configuration_url, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) From 6c5ba536687e90397f8d5b4c0b23b23126685d4e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Nov 2023 13:12:26 +0100 Subject: [PATCH 291/982] Bump pyOverkiz to 1.13.0 (#103582) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 3b3afddc489..f57e351a282 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.12.1"], + "requirements": ["pyoverkiz==1.13.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 5b9271294e8..1fa8c90a7b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1938,7 +1938,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.12.1 +pyoverkiz==1.13.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba028f8246b..dedeeef4c47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1463,7 +1463,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.12.1 +pyoverkiz==1.13.0 # homeassistant.components.openweathermap pyowm==3.2.0 From 38acad8263e30abf4934ed2748bf850fb18876fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Vasek?= Date: Tue, 7 Nov 2023 13:14:30 +0100 Subject: [PATCH 292/982] Add geofencing mode for Somfy Thermostat in Overkiz (#103160) 103136 Added geofencing mode for Overkiz thermostat --- .../components/overkiz/climate_entities/somfy_thermostat.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index 7409b5307cf..4059f8521b8 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -33,10 +33,12 @@ OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = { OverkizCommandParam.AT_HOME_MODE: PRESET_HOME, OverkizCommandParam.AWAY_MODE: PRESET_AWAY, OverkizCommandParam.FREEZE_MODE: PRESET_FREEZE, + OverkizCommandParam.GEOFENCING_MODE: PRESET_NONE, OverkizCommandParam.MANUAL_MODE: PRESET_NONE, OverkizCommandParam.SLEEPING_MODE: PRESET_NIGHT, OverkizCommandParam.SUDDEN_DROP_MODE: PRESET_NONE, } + PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} TARGET_TEMP_TO_OVERKIZ = { PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE, From c13744f4cffe72b639101b468c2a073b8bb5ff1a Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 7 Nov 2023 08:11:54 -0500 Subject: [PATCH 293/982] Remove MyQ Integration (#103565) Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- .coveragerc | 3 - CODEOWNERS | 2 - homeassistant/components/myq/__init__.py | 126 +++---------- homeassistant/components/myq/binary_sensor.py | 52 ------ homeassistant/components/myq/config_flow.py | 92 +--------- homeassistant/components/myq/const.py | 36 ---- homeassistant/components/myq/cover.py | 116 ------------ homeassistant/components/myq/light.py | 76 -------- homeassistant/components/myq/manifest.json | 15 +- homeassistant/components/myq/strings.json | 29 +-- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/dhcp.py | 4 - homeassistant/generated/integrations.json | 6 - homeassistant/generated/zeroconf.py | 8 - pyproject.toml | 2 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/myq/fixtures/devices.json | 163 ----------------- tests/components/myq/test_binary_sensor.py | 20 --- tests/components/myq/test_config_flow.py | 166 ------------------ tests/components/myq/test_cover.py | 50 ------ tests/components/myq/test_init.py | 50 ++++++ tests/components/myq/test_light.py | 39 ---- tests/components/myq/util.py | 54 ------ 24 files changed, 79 insertions(+), 1037 deletions(-) delete mode 100644 homeassistant/components/myq/binary_sensor.py delete mode 100644 homeassistant/components/myq/const.py delete mode 100644 homeassistant/components/myq/cover.py delete mode 100644 homeassistant/components/myq/light.py delete mode 100644 tests/components/myq/fixtures/devices.json delete mode 100644 tests/components/myq/test_binary_sensor.py delete mode 100644 tests/components/myq/test_config_flow.py delete mode 100644 tests/components/myq/test_cover.py create mode 100644 tests/components/myq/test_init.py delete mode 100644 tests/components/myq/test_light.py delete mode 100644 tests/components/myq/util.py diff --git a/.coveragerc b/.coveragerc index e41d668ed56..99685df79d7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -769,9 +769,6 @@ omit = homeassistant/components/mutesync/binary_sensor.py homeassistant/components/mvglive/sensor.py homeassistant/components/mycroft/* - homeassistant/components/myq/__init__.py - homeassistant/components/myq/cover.py - homeassistant/components/myq/light.py homeassistant/components/mysensors/__init__.py homeassistant/components/mysensors/climate.py homeassistant/components/mysensors/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index b77916932d1..0a594d71b77 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -811,8 +811,6 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core -/homeassistant/components/myq/ @ehendrix23 @Lash-L -/tests/components/myq/ @ehendrix23 @Lash-L /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index c50ea579a14..86a158c09fa 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -1,122 +1,38 @@ """The MyQ integration.""" from __future__ import annotations -from datetime import timedelta -import logging - -import pymyq -from pymyq.const import ( - DEVICE_STATE as MYQ_DEVICE_STATE, - DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, - KNOWN_MODELS, - MANUFACTURER, -) -from pymyq.device import MyQDevice -from pymyq.errors import InvalidCredentialsError, MyQError - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, PLATFORMS, UPDATE_INTERVAL - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "myq" -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Set up MyQ from a config entry.""" - - hass.data.setdefault(DOMAIN, {}) - websession = aiohttp_client.async_get_clientsession(hass) - conf = entry.data - - try: - myq = await pymyq.login(conf[CONF_USERNAME], conf[CONF_PASSWORD], websession) - except InvalidCredentialsError as err: - raise ConfigEntryAuthFailed from err - except MyQError as err: - raise ConfigEntryNotReady from err - - # Called by DataUpdateCoordinator, allows to capture any MyQError exceptions and to throw an HASS UpdateFailed - # exception instead, preventing traceback in HASS logs. - async def async_update_data(): - try: - return await myq.update_device_info() - except InvalidCredentialsError as err: - raise ConfigEntryAuthFailed from err - except MyQError as err: - raise UpdateFailed(str(err)) from err - - coordinator = DataUpdateCoordinator( + ir.async_create_issue( hass, - _LOGGER, - name="myq devices", - update_method=async_update_data, - update_interval=timedelta(seconds=UPDATE_INTERVAL), + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "blog": "https://www.home-assistant.io/blog/2023/11/06/removal-of-myq-integration/", + "entries": "/config/integrations/integration/myQ", + }, ) - hass.data[DOMAIN][entry.entry_id] = {MYQ_GATEWAY: myq, MYQ_COORDINATOR: coordinator} - - 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.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return unload_ok - - -class MyQEntity(CoordinatorEntity): - """Base class for MyQ Entities.""" - - def __init__(self, coordinator: DataUpdateCoordinator, device: MyQDevice) -> None: - """Initialize class.""" - super().__init__(coordinator) - self._device = device - self._attr_unique_id = device.device_id - - @property - def name(self): - """Return the name if any, name can change if user changes it within MyQ.""" - return self._device.name - - @property - def device_info(self): - """Return the device_info of the device.""" - model = ( - KNOWN_MODELS.get(self._device.device_id[2:4]) - if self._device.device_id is not None - else None - ) - via_device: tuple[str, str] | None = None - if self._device.parent_device_id: - via_device = (DOMAIN, self._device.parent_device_id) - return DeviceInfo( - identifiers={(DOMAIN, self._device.device_id)}, - manufacturer=MANUFACTURER, - model=model, - name=self._device.name, - sw_version=self._device.firmware_version, - via_device=via_device, - ) - - @property - def available(self): - """Return if the device is online.""" - # Not all devices report online so assume True if its missing - return super().available and self._device.device_json[MYQ_DEVICE_STATE].get( - MYQ_DEVICE_STATE_ONLINE, True - ) + return True diff --git a/homeassistant/components/myq/binary_sensor.py b/homeassistant/components/myq/binary_sensor.py deleted file mode 100644 index f4c976a5879..00000000000 --- a/homeassistant/components/myq/binary_sensor.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Support for MyQ gateways.""" -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MyQEntity -from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up mysq covers.""" - data = hass.data[DOMAIN][config_entry.entry_id] - myq = data[MYQ_GATEWAY] - coordinator = data[MYQ_COORDINATOR] - - entities = [] - - for device in myq.gateways.values(): - entities.append(MyQBinarySensorEntity(coordinator, device)) - - async_add_entities(entities) - - -class MyQBinarySensorEntity(MyQEntity, BinarySensorEntity): - """Representation of a MyQ gateway.""" - - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - _attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def name(self): - """Return the name of the garage door if any.""" - return f"{self._device.name} MyQ Gateway" - - @property - def is_on(self): - """Return if the device is online.""" - return super().available - - @property - def available(self) -> bool: - """Entity is always available.""" - return True diff --git a/homeassistant/components/myq/config_flow.py b/homeassistant/components/myq/config_flow.py index 930d0014d1f..27bb1c4b9e5 100644 --- a/homeassistant/components/myq/config_flow.py +++ b/homeassistant/components/myq/config_flow.py @@ -1,101 +1,11 @@ """Config flow for MyQ integration.""" -from collections.abc import Mapping -import logging -from typing import Any - -import pymyq -from pymyq.errors import InvalidCredentialsError, MyQError -import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} -) +from . import DOMAIN class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for MyQ.""" VERSION = 1 - - def __init__(self) -> None: - """Start a myq config flow.""" - self._reauth_unique_id = None - - async def _async_validate_input(self, username, password): - """Validate the user input allows us to connect.""" - websession = aiohttp_client.async_get_clientsession(self.hass) - try: - await pymyq.login(username, password, websession, True) - except InvalidCredentialsError: - return {CONF_PASSWORD: "invalid_auth"} - except MyQError: - return {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - return {"base": "unknown"} - - return None - - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - errors = {} - if user_input is not None: - errors = await self._async_validate_input( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD] - ) - if not errors: - await self.async_set_unique_id(user_input[CONF_USERNAME]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input - ) - - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) - - async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: - """Handle reauth.""" - self._reauth_unique_id = self.context["unique_id"] - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm(self, user_input=None): - """Handle reauth input.""" - errors = {} - existing_entry = await self.async_set_unique_id(self._reauth_unique_id) - if user_input is not None: - errors = await self._async_validate_input( - existing_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] - ) - if not errors: - self.hass.config_entries.async_update_entry( - existing_entry, - data={ - **existing_entry.data, - CONF_PASSWORD: user_input[CONF_PASSWORD], - }, - ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - - return self.async_show_form( - description_placeholders={ - CONF_USERNAME: existing_entry.data[CONF_USERNAME] - }, - step_id="reauth_confirm", - data_schema=vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - ) diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py deleted file mode 100644 index 16dead34477..00000000000 --- a/homeassistant/components/myq/const.py +++ /dev/null @@ -1,36 +0,0 @@ -"""The MyQ integration.""" -from pymyq.garagedoor import ( - STATE_CLOSED as MYQ_COVER_STATE_CLOSED, - STATE_CLOSING as MYQ_COVER_STATE_CLOSING, - STATE_OPEN as MYQ_COVER_STATE_OPEN, - STATE_OPENING as MYQ_COVER_STATE_OPENING, -) -from pymyq.lamp import STATE_OFF as MYQ_LIGHT_STATE_OFF, STATE_ON as MYQ_LIGHT_STATE_ON - -from homeassistant.const import ( - STATE_CLOSED, - STATE_CLOSING, - STATE_OFF, - STATE_ON, - STATE_OPEN, - STATE_OPENING, - Platform, -) - -DOMAIN = "myq" - -PLATFORMS = [Platform.COVER, Platform.BINARY_SENSOR, Platform.LIGHT] - -MYQ_TO_HASS = { - MYQ_COVER_STATE_CLOSED: STATE_CLOSED, - MYQ_COVER_STATE_CLOSING: STATE_CLOSING, - MYQ_COVER_STATE_OPEN: STATE_OPEN, - MYQ_COVER_STATE_OPENING: STATE_OPENING, - MYQ_LIGHT_STATE_ON: STATE_ON, - MYQ_LIGHT_STATE_OFF: STATE_OFF, -} - -MYQ_GATEWAY = "myq_gateway" -MYQ_COORDINATOR = "coordinator" - -UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py deleted file mode 100644 index 51d0b3290a6..00000000000 --- a/homeassistant/components/myq/cover.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Support for MyQ-Enabled Garage Doors.""" -from typing import Any - -from pymyq.const import DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE -from pymyq.errors import MyQError - -from homeassistant.components.cover import ( - CoverDeviceClass, - CoverEntity, - CoverEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MyQEntity -from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up mysq covers.""" - data = hass.data[DOMAIN][config_entry.entry_id] - myq = data[MYQ_GATEWAY] - coordinator = data[MYQ_COORDINATOR] - - async_add_entities( - [MyQCover(coordinator, device) for device in myq.covers.values()] - ) - - -class MyQCover(MyQEntity, CoverEntity): - """Representation of a MyQ cover.""" - - _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - - def __init__(self, coordinator, device): - """Initialize with API object, device id.""" - super().__init__(coordinator, device) - self._device = device - if device.device_type == MYQ_DEVICE_TYPE_GATE: - self._attr_device_class = CoverDeviceClass.GATE - else: - self._attr_device_class = CoverDeviceClass.GARAGE - self._attr_unique_id = device.device_id - - @property - def is_closed(self) -> bool: - """Return true if cover is closed, else False.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSED - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING - - @property - def is_open(self) -> bool: - """Return if the cover is opening or not.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_OPEN - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING - - async def async_close_cover(self, **kwargs: Any) -> None: - """Issue close command to cover.""" - if self.is_closing or self.is_closed: - return - - try: - wait_task = await self._device.close(wait_for_state=False) - except MyQError as err: - raise HomeAssistantError( - f"Closing of cover {self._device.name} failed with error: {err}" - ) from err - - # Write closing state to HASS - self.async_write_ha_state() - - result = wait_task if isinstance(wait_task, bool) else await wait_task - - # Write final state to HASS - self.async_write_ha_state() - - if not result: - raise HomeAssistantError(f"Closing of cover {self._device.name} failed") - - async def async_open_cover(self, **kwargs: Any) -> None: - """Issue open command to cover.""" - if self.is_opening or self.is_open: - return - - try: - wait_task = await self._device.open(wait_for_state=False) - except MyQError as err: - raise HomeAssistantError( - f"Opening of cover {self._device.name} failed with error: {err}" - ) from err - - # Write opening state to HASS - self.async_write_ha_state() - - result = wait_task if isinstance(wait_task, bool) else await wait_task - - # Write final state to HASS - self.async_write_ha_state() - - if not result: - raise HomeAssistantError(f"Opening of cover {self._device.name} failed") diff --git a/homeassistant/components/myq/light.py b/homeassistant/components/myq/light.py deleted file mode 100644 index 684af64a82e..00000000000 --- a/homeassistant/components/myq/light.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Support for MyQ-Enabled lights.""" -from typing import Any - -from pymyq.errors import MyQError - -from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MyQEntity -from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up myq lights.""" - data = hass.data[DOMAIN][config_entry.entry_id] - myq = data[MYQ_GATEWAY] - coordinator = data[MYQ_COORDINATOR] - - async_add_entities( - [MyQLight(coordinator, device) for device in myq.lamps.values()], True - ) - - -class MyQLight(MyQEntity, LightEntity): - """Representation of a MyQ light.""" - - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} - - @property - def is_on(self): - """Return true if the light is on, else False.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_ON - - @property - def is_off(self): - """Return true if the light is off, else False.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_OFF - - async def async_turn_on(self, **kwargs: Any) -> None: - """Issue on command to light.""" - if self.is_on: - return - - try: - await self._device.turnon(wait_for_state=True) - except MyQError as err: - raise HomeAssistantError( - f"Turning light {self._device.name} on failed with error: {err}" - ) from err - - # Write new state to HASS - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Issue off command to light.""" - if self.is_off: - return - - try: - await self._device.turnoff(wait_for_state=True) - except MyQError as err: - raise HomeAssistantError( - f"Turning light {self._device.name} off failed with error: {err}" - ) from err - - # Write new state to HASS - self.async_write_ha_state() diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index e924d06955b..dd265c4a428 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -1,18 +1,9 @@ { "domain": "myq", "name": "MyQ", - "codeowners": ["@ehendrix23", "@Lash-L"], - "config_flow": true, - "dhcp": [ - { - "macaddress": "645299*" - } - ], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/myq", - "homekit": { - "models": ["819LMB", "MYQ"] - }, + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pkce", "pymyq"], - "requirements": ["python-myq==3.1.13"] + "requirements": [] } diff --git a/homeassistant/components/myq/strings.json b/homeassistant/components/myq/strings.json index c986b8a8997..85359302c99 100644 --- a/homeassistant/components/myq/strings.json +++ b/homeassistant/components/myq/strings.json @@ -1,29 +1,8 @@ { - "config": { - "step": { - "user": { - "title": "Connect to the MyQ Gateway", - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - }, - "reauth_confirm": { - "description": "The password for {username} is no longer valid.", - "title": "Reauthenticate your MyQ Account", - "data": { - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "issues": { + "integration_removed": { + "title": "The MyQ integration has been removed", + "description": "The MyQ integration has been removed from Home Assistant.\n\nMyQ has blocked all third-party integrations. Read about it [here]({blog}).\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing MyQ integration entries]({entries})." } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b7f112783ad..4cd6a93d83a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -301,7 +301,6 @@ FLOWS = { "mqtt", "mullvad", "mutesync", - "myq", "mysensors", "mystrom", "nam", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index bc73c1b9804..63c7cd84303 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -316,10 +316,6 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "motion_blinds", "hostname": "connector_*", }, - { - "domain": "myq", - "macaddress": "645299*", - }, { "domain": "nest", "macaddress": "18B430*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index dca042bed20..475a1c47eb2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3625,12 +3625,6 @@ "config_flow": false, "iot_class": "local_push" }, - "myq": { - "name": "MyQ", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "mysensors": { "name": "MySensors", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 36ddfd68479..485d16e46e7 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -20,10 +20,6 @@ HOMEKIT = { "always_discover": True, "domain": "roku", }, - "819LMB": { - "always_discover": True, - "domain": "myq", - }, "AC02": { "always_discover": True, "domain": "tado", @@ -144,10 +140,6 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, - "MYQ": { - "always_discover": True, - "domain": "myq", - }, "NL29": { "always_discover": False, "domain": "nanoleaf", diff --git a/pyproject.toml b/pyproject.toml index 60557c3948e..5c3bee507ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -528,8 +528,6 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated:DeprecationWarning:proto.datetime_helpers", # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", - # https://github.com/Python-MyQ/Python-MyQ - v3.1.13 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pymyq.(api|account)", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", diff --git a/requirements_all.txt b/requirements_all.txt index 1fa8c90a7b6..c2283c5310b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2161,9 +2161,6 @@ python-miio==0.5.12 # homeassistant.components.mpd python-mpd2==3.0.5 -# homeassistant.components.myq -python-myq==3.1.13 - # homeassistant.components.mystrom python-mystrom==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dedeeef4c47..0b9220e21f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1611,9 +1611,6 @@ python-matter-server==4.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 -# homeassistant.components.myq -python-myq==3.1.13 - # homeassistant.components.mystrom python-mystrom==2.2.0 diff --git a/tests/components/myq/fixtures/devices.json b/tests/components/myq/fixtures/devices.json deleted file mode 100644 index 0966845e3ca..00000000000 --- a/tests/components/myq/fixtures/devices.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "count": 6, - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices", - "items": [ - { - "device_type": "ethernetgateway", - "created_date": "2020-02-10T22:54:58.423", - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", - "device_family": "gateway", - "name": "Happy place", - "device_platform": "myq", - "state": { - "homekit_enabled": false, - "pending_bootload_abandoned": false, - "online": true, - "last_status": "2020-03-30T02:49:46.4121303Z", - "physical_devices": [], - "firmware_version": "1.6", - "learn_mode": false, - "learn": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial/learn", - "homekit_capable": false, - "updated_date": "2020-03-30T02:49:46.4171299Z" - }, - "serial_number": "gateway_serial" - }, - { - "serial_number": "gate_serial", - "state": { - "report_ajar": false, - "aux_relay_delay": "00:00:00", - "is_unattended_close_allowed": true, - "door_ajar_interval": "00:00:00", - "aux_relay_behavior": "None", - "last_status": "2020-03-30T02:47:40.2794038Z", - "online": true, - "rex_fires_door": false, - "close": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial/close", - "invalid_shutout_period": "00:00:00", - "invalid_credential_window": "00:00:00", - "use_aux_relay": false, - "command_channel_report_status": false, - "last_update": "2020-03-28T23:07:39.5611776Z", - "door_state": "closed", - "max_invalid_attempts": 0, - "open": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial/open", - "passthrough_interval": "00:00:00", - "control_from_browser": false, - "report_forced": false, - "is_unattended_open_allowed": true - }, - "parent_device_id": "gateway_serial", - "name": "Gate", - "device_platform": "myq", - "device_family": "garagedoor", - "parent_device": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial", - "device_type": "gate", - "created_date": "2020-02-10T22:54:58.423" - }, - { - "parent_device": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial", - "device_type": "wifigaragedooropener", - "created_date": "2020-02-10T22:55:25.863", - "device_platform": "myq", - "name": "Large Garage Door", - "device_family": "garagedoor", - "serial_number": "large_garage_serial", - "state": { - "report_forced": false, - "is_unattended_open_allowed": true, - "passthrough_interval": "00:00:00", - "control_from_browser": false, - "attached_work_light_error_present": false, - "max_invalid_attempts": 0, - "open": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial/open", - "command_channel_report_status": false, - "last_update": "2020-03-28T23:58:55.5906643Z", - "door_state": "closed", - "invalid_shutout_period": "00:00:00", - "use_aux_relay": false, - "invalid_credential_window": "00:00:00", - "rex_fires_door": false, - "close": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial/close", - "online": true, - "last_status": "2020-03-30T02:49:46.4121303Z", - "aux_relay_behavior": "None", - "door_ajar_interval": "00:00:00", - "gdo_lock_connected": false, - "report_ajar": false, - "aux_relay_delay": "00:00:00", - "is_unattended_close_allowed": true - }, - "parent_device_id": "gateway_serial" - }, - { - "serial_number": "small_garage_serial", - "state": { - "last_status": "2020-03-30T02:48:45.7501595Z", - "online": true, - "report_ajar": false, - "aux_relay_delay": "00:00:00", - "is_unattended_close_allowed": true, - "gdo_lock_connected": false, - "door_ajar_interval": "00:00:00", - "aux_relay_behavior": "None", - "attached_work_light_error_present": false, - "control_from_browser": false, - "passthrough_interval": "00:00:00", - "is_unattended_open_allowed": true, - "report_forced": false, - "close": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial/close", - "rex_fires_door": false, - "invalid_credential_window": "00:00:00", - "use_aux_relay": false, - "invalid_shutout_period": "00:00:00", - "door_state": "closed", - "last_update": "2020-03-26T15:45:31.4713796Z", - "command_channel_report_status": false, - "open": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial/open", - "max_invalid_attempts": 0 - }, - "parent_device_id": "gateway_serial", - "device_platform": "myq", - "name": "Small Garage Door", - "device_family": "garagedoor", - "parent_device": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial", - "device_type": "wifigaragedooropener", - "created_date": "2020-02-10T23:11:47.487" - }, - { - "serial_number": "garage_light_off", - "state": { - "last_status": "2020-03-30T02:48:45.7501595Z", - "online": true, - "lamp_state": "off", - "last_update": "2020-03-26T15:45:31.4713796Z" - }, - "parent_device_id": "gateway_serial", - "device_platform": "myq", - "name": "Garage Door Light Off", - "device_family": "lamp", - "device_type": "lamp", - "created_date": "2020-02-10T23:11:47.487" - }, - { - "serial_number": "garage_light_on", - "state": { - "last_status": "2020-03-30T02:48:45.7501595Z", - "online": true, - "lamp_state": "on", - "last_update": "2020-03-26T15:45:31.4713796Z" - }, - "parent_device_id": "gateway_serial", - "device_platform": "myq", - "name": "Garage Door Light On", - "device_family": "lamp", - "device_type": "lamp", - "created_date": "2020-02-10T23:11:47.487" - } - ] -} diff --git a/tests/components/myq/test_binary_sensor.py b/tests/components/myq/test_binary_sensor.py deleted file mode 100644 index 39a2a4dff3a..00000000000 --- a/tests/components/myq/test_binary_sensor.py +++ /dev/null @@ -1,20 +0,0 @@ -"""The scene tests for the myq platform.""" -from homeassistant.const import STATE_ON -from homeassistant.core import HomeAssistant - -from .util import async_init_integration - - -async def test_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of binary_sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("binary_sensor.happy_place_myq_gateway") - assert state.state == STATE_ON - expected_attributes = {"device_class": "connectivity"} - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) diff --git a/tests/components/myq/test_config_flow.py b/tests/components/myq/test_config_flow.py deleted file mode 100644 index 2df69168852..00000000000 --- a/tests/components/myq/test_config_flow.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Test the MyQ config flow.""" -from unittest.mock import patch - -from pymyq.errors import InvalidCredentialsError, MyQError - -from homeassistant import config_entries -from homeassistant.components.myq.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_form_user(hass: HomeAssistant) -> None: - """Test we get the user form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - return_value=True, - ), patch( - "homeassistant.components.myq.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=InvalidCredentialsError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"password": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=MyQError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test we handle unknown exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test we can reauth.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "test@test.org", - CONF_PASSWORD: "secret", - }, - unique_id="test@test.org", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_REAUTH, "unique_id": "test@test.org"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=InvalidCredentialsError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"password": "invalid_auth"} - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=MyQError, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert result3["type"] == "form" - assert result3["errors"] == {"base": "cannot_connect"} - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - return_value=True, - ), patch( - "homeassistant.components.myq.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert mock_setup_entry.called - assert result4["type"] == "abort" - assert result4["reason"] == "reauth_successful" diff --git a/tests/components/myq/test_cover.py b/tests/components/myq/test_cover.py deleted file mode 100644 index b8d6cf53736..00000000000 --- a/tests/components/myq/test_cover.py +++ /dev/null @@ -1,50 +0,0 @@ -"""The scene tests for the myq platform.""" -from homeassistant.const import STATE_CLOSED -from homeassistant.core import HomeAssistant - -from .util import async_init_integration - - -async def test_create_covers(hass: HomeAssistant) -> None: - """Test creation of covers.""" - - await async_init_integration(hass) - - state = hass.states.get("cover.large_garage_door") - assert state.state == STATE_CLOSED - expected_attributes = { - "device_class": "garage", - "friendly_name": "Large Garage Door", - "supported_features": 3, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) - - state = hass.states.get("cover.small_garage_door") - assert state.state == STATE_CLOSED - expected_attributes = { - "device_class": "garage", - "friendly_name": "Small Garage Door", - "supported_features": 3, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) - - state = hass.states.get("cover.gate") - assert state.state == STATE_CLOSED - expected_attributes = { - "device_class": "gate", - "friendly_name": "Gate", - "supported_features": 3, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) diff --git a/tests/components/myq/test_init.py b/tests/components/myq/test_init.py new file mode 100644 index 00000000000..24e03f56075 --- /dev/null +++ b/tests/components/myq/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the MyQ Connected Services integration.""" + +from homeassistant.components.myq import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_myq_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the MyQ configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/myq/test_light.py b/tests/components/myq/test_light.py deleted file mode 100644 index ca80e768779..00000000000 --- a/tests/components/myq/test_light.py +++ /dev/null @@ -1,39 +0,0 @@ -"""The scene tests for the myq platform.""" -from homeassistant.components.light import ColorMode -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant - -from .util import async_init_integration - - -async def test_create_lights(hass: HomeAssistant) -> None: - """Test creation of lights.""" - - await async_init_integration(hass) - - state = hass.states.get("light.garage_door_light_off") - assert state.state == STATE_OFF - expected_attributes = { - "friendly_name": "Garage Door Light Off", - "supported_features": 0, - "supported_color_modes": [ColorMode.ONOFF], - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) - - state = hass.states.get("light.garage_door_light_on") - assert state.state == STATE_ON - expected_attributes = { - "friendly_name": "Garage Door Light On", - "supported_features": 0, - "supported_color_modes": [ColorMode.ONOFF], - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) diff --git a/tests/components/myq/util.py b/tests/components/myq/util.py deleted file mode 100644 index 8cb0d17f592..00000000000 --- a/tests/components/myq/util.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for the myq integration.""" -import json -import logging -from unittest.mock import patch - -from pymyq.const import ACCOUNTS_ENDPOINT, DEVICES_ENDPOINT - -from homeassistant.components.myq.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry, load_fixture - -_LOGGER = logging.getLogger(__name__) - - -async def async_init_integration( - hass: HomeAssistant, - skip_setup: bool = False, -) -> MockConfigEntry: - """Set up the myq integration in Home Assistant.""" - - devices_fixture = "myq/devices.json" - devices_json = load_fixture(devices_fixture) - devices_dict = json.loads(devices_json) - - def _handle_mock_api_oauth_authenticate(): - return 1234, 1800 - - def _handle_mock_api_request(method, returns, url, **kwargs): - _LOGGER.debug("URL: %s", url) - if url == ACCOUNTS_ENDPOINT: - _LOGGER.debug("Accounts") - return None, {"accounts": [{"id": 1, "name": "mock"}]} - if url == DEVICES_ENDPOINT.format(account_id=1): - _LOGGER.debug("Devices") - return None, devices_dict - _LOGGER.debug("Something else") - return None, {} - - with patch( - "pymyq.api.API._oauth_authenticate", - side_effect=_handle_mock_api_oauth_authenticate, - ), patch("pymyq.api.API.request", side_effect=_handle_mock_api_request): - entry = MockConfigEntry( - domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} - ) - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry From b636a4d5cf51957e8789fccb32e2a2d433af49c6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 7 Nov 2023 14:13:57 +0100 Subject: [PATCH 294/982] Parametrize DSMR serial config flow tests (#103524) --- tests/components/dsmr/test_config_flow.py | 255 ++++++---------------- 1 file changed, 68 insertions(+), 187 deletions(-) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index c4bbe9a7086..55395b92270 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -2,14 +2,17 @@ import asyncio from itertools import chain, repeat import os +from typing import Any from unittest.mock import DEFAULT, AsyncMock, MagicMock, patch, sentinel +import pytest import serial import serial.tools.list_ports from homeassistant import config_entries, data_entry_flow from homeassistant.components.dsmr import DOMAIN, config_flow from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry @@ -123,9 +126,68 @@ async def test_setup_network_rfxtrx( assert result["data"] == {**entry_data, **SERIAL_DATA} +@pytest.mark.parametrize( + ("version", "entry_data"), + [ + ( + "2.2", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "2.2", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": "123456789", + }, + ), + ( + "5B", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "5B", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": "123456789", + }, + ), + ( + "5L", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "5L", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": "123456789", + }, + ), + ( + "5S", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "5S", + "protocol": "dsmr_protocol", + "serial_id": None, + "serial_id_gas": None, + }, + ), + ( + "Q3D", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "Q3D", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": None, + }, + ), + ], +) @patch("serial.tools.list_ports.comports", return_value=[com_port()]) async def test_setup_serial( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture + com_mock, + hass: HomeAssistant, + dsmr_connection_send_validate_fixture, + version: str, + entry_data: dict[str, Any], ) -> None: """Test we can setup serial.""" port = com_port() @@ -134,7 +196,7 @@ async def test_setup_serial( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] is None @@ -143,26 +205,20 @@ async def test_setup_serial( {"type": "Serial"}, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "setup_serial" assert result["errors"] == {} with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"port": port.device, "dsmr_version": "2.2"}, + {"port": port.device, "dsmr_version": version}, ) await hass.async_block_till_done() - entry_data = { - "port": port.device, - "dsmr_version": "2.2", - "protocol": "dsmr_protocol", - } - - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == port.device - assert result["data"] == {**entry_data, **SERIAL_DATA} + assert result["data"] == entry_data @patch("serial.tools.list_ports.comports", return_value=[com_port()]) @@ -215,181 +271,6 @@ async def test_setup_serial_rfxtrx( assert result["data"] == {**entry_data, **SERIAL_DATA} -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) -async def test_setup_5B( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture -) -> None: - """Test we can setup serial.""" - port = com_port() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "setup_serial" - assert result["errors"] == {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"port": port.device, "dsmr_version": "5B"}, - ) - await hass.async_block_till_done() - - entry_data = { - "port": port.device, - "dsmr_version": "5B", - "protocol": "dsmr_protocol", - "serial_id": "12345678", - "serial_id_gas": "123456789", - } - - assert result["type"] == "create_entry" - assert result["title"] == port.device - assert result["data"] == entry_data - - -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) -async def test_setup_5L( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture -) -> None: - """Test we can setup serial.""" - port = com_port() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "setup_serial" - assert result["errors"] == {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"port": port.device, "dsmr_version": "5L"}, - ) - await hass.async_block_till_done() - - entry_data = { - "port": port.device, - "dsmr_version": "5L", - "protocol": "dsmr_protocol", - "serial_id": "12345678", - "serial_id_gas": "123456789", - } - - assert result["type"] == "create_entry" - assert result["title"] == port.device - assert result["data"] == entry_data - - -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) -async def test_setup_5S( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture -) -> None: - """Test we can setup serial.""" - port = com_port() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "setup_serial" - assert result["errors"] == {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"port": port.device, "dsmr_version": "5S"} - ) - await hass.async_block_till_done() - - entry_data = { - "port": port.device, - "dsmr_version": "5S", - "protocol": "dsmr_protocol", - "serial_id": None, - "serial_id_gas": None, - } - - assert result["type"] == "create_entry" - assert result["title"] == port.device - assert result["data"] == entry_data - - -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) -async def test_setup_Q3D( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture -) -> None: - """Test we can setup serial.""" - port = com_port() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "setup_serial" - assert result["errors"] == {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"port": port.device, "dsmr_version": "Q3D"}, - ) - await hass.async_block_till_done() - - entry_data = { - "port": port.device, - "dsmr_version": "Q3D", - "protocol": "dsmr_protocol", - "serial_id": "12345678", - "serial_id_gas": None, - } - - assert result["type"] == "create_entry" - assert result["title"] == port.device - assert result["data"] == entry_data - - @patch("serial.tools.list_ports.comports", return_value=[com_port()]) async def test_setup_serial_manual( com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture From 5b433518070dec6382db14073fe2451d25381839 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Tue, 7 Nov 2023 14:46:02 +0100 Subject: [PATCH 295/982] fix: get_devices only checks for the first type (#103583) --- homeassistant/components/lupusec/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index b4294216003..981a2a8633a 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -28,9 +28,10 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - devices = [] + device_types = [CONST.TYPE_SWITCH] - for device in data.lupusec.get_devices(generic_type=CONST.TYPE_SWITCH): + devices = [] + for device in data.lupusec.get_devices(generic_type=device_types): devices.append(LupusecSwitch(data, device)) add_entities(devices) From 21af563dfebc820e97bbf06a09a3ea577a39034e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 Nov 2023 15:06:34 +0100 Subject: [PATCH 296/982] Bump python-songpal to 0.16 (#103561) * Bump python-songpal to 0.16 * Set _attr_device_class --- homeassistant/components/songpal/manifest.json | 2 +- homeassistant/components/songpal/media_player.py | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index aa1157e8d0b..ce78b8c9f03 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["songpal"], "quality_scale": "gold", - "requirements": ["python-songpal==0.15.2"], + "requirements": ["python-songpal==0.16"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 79fab9a2651..7a8ced30eb7 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -16,6 +16,7 @@ from songpal import ( import voluptuous as vol from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -91,6 +92,7 @@ class SongpalEntity(MediaPlayerEntity): """Class representing a Songpal device.""" _attr_should_poll = False + _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP diff --git a/requirements_all.txt b/requirements_all.txt index c2283c5310b..e966e0871f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2190,7 +2190,7 @@ python-roborock==0.35.0 python-smarttub==0.0.35 # homeassistant.components.songpal -python-songpal==0.15.2 +python-songpal==0.16 # homeassistant.components.tado python-tado==0.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b9220e21f4..d469ed5038d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1634,7 +1634,7 @@ python-roborock==0.35.0 python-smarttub==0.0.35 # homeassistant.components.songpal -python-songpal==0.15.2 +python-songpal==0.16 # homeassistant.components.tado python-tado==0.15.0 From 0fcaa2c5811a1bb718094e9e216c232c3b9caaf8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 7 Nov 2023 09:11:38 -0500 Subject: [PATCH 297/982] Add `zwave_js.refresh_notifications` service (#101370) --- homeassistant/components/zwave_js/const.py | 3 + homeassistant/components/zwave_js/services.py | 124 +++++++++++++----- .../components/zwave_js/services.yaml | 22 ++++ .../components/zwave_js/strings.json | 14 ++ .../fixtures/multisensor_6_state.json | 9 +- tests/components/zwave_js/test_services.py | 97 ++++++++++++++ 6 files changed, 236 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 34c6fa3363e..5d4a8c574bf 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -72,6 +72,8 @@ ATTR_STATUS = "status" ATTR_ACKNOWLEDGED_FRAMES = "acknowledged_frames" ATTR_EVENT_TYPE_LABEL = "event_type_label" ATTR_DATA_TYPE_LABEL = "data_type_label" +ATTR_NOTIFICATION_TYPE = "notification_type" +ATTR_NOTIFICATION_EVENT = "notification_event" ATTR_NODE = "node" ATTR_ZWAVE_VALUE = "zwave_value" @@ -92,6 +94,7 @@ SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" SERVICE_INVOKE_CC_API = "invoke_cc_api" SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" SERVICE_PING = "ping" +SERVICE_REFRESH_NOTIFICATIONS = "refresh_notifications" SERVICE_REFRESH_VALUE = "refresh_value" SERVICE_RESET_METER = "reset_meter" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 44ef3a2269c..20485d8a922 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -4,11 +4,12 @@ from __future__ import annotations import asyncio from collections.abc import Generator, Sequence import logging -from typing import Any +from typing import Any, TypeVar import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import SET_VALUE_SUCCESS, CommandClass, CommandStatus +from zwave_js_server.const.command_class.notification import NotificationType from zwave_js_server.exceptions import FailedZWaveCommand, SetValueFailed from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.node import Node as ZwaveNode @@ -39,6 +40,8 @@ from .helpers import ( _LOGGER = logging.getLogger(__name__) +T = TypeVar("T", ZwaveNode, Endpoint) + def parameter_name_does_not_need_bitmask( val: dict[str, int | str | list[str]] @@ -66,8 +69,8 @@ def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: def get_valid_responses_from_results( - zwave_objects: Sequence[ZwaveNode | Endpoint], results: Sequence[Any] -) -> Generator[tuple[ZwaveNode | Endpoint, Any], None, None]: + zwave_objects: Sequence[T], results: Sequence[Any] +) -> Generator[tuple[T, Any], None, None]: """Return valid responses from a list of results.""" for zwave_object, result in zip(zwave_objects, results): if not isinstance(result, Exception): @@ -93,6 +96,49 @@ def raise_exceptions_from_results( raise HomeAssistantError("\n".join(lines)) +async def _async_invoke_cc_api( + nodes_or_endpoints: set[T], + command_class: CommandClass, + method_name: str, + *args: Any, +) -> None: + """Invoke the CC API on a node endpoint.""" + nodes_or_endpoints_list = list(nodes_or_endpoints) + results = await asyncio.gather( + *( + node_or_endpoint.async_invoke_cc_api(command_class, method_name, *args) + for node_or_endpoint in nodes_or_endpoints_list + ), + return_exceptions=True, + ) + for node_or_endpoint, result in get_valid_responses_from_results( + nodes_or_endpoints_list, results + ): + if isinstance(node_or_endpoint, ZwaveNode): + _LOGGER.info( + ( + "Invoked %s CC API method %s on node %s with the following result: " + "%s" + ), + command_class.name, + method_name, + node_or_endpoint, + result, + ) + else: + _LOGGER.info( + ( + "Invoked %s CC API method %s on endpoint %s with the following " + "result: %s" + ), + command_class.name, + method_name, + node_or_endpoint, + result, + ) + raise_exceptions_from_results(nodes_or_endpoints_list, results) + + class ZWaveServices: """Class that holds our services (Zwave Commands). @@ -406,6 +452,34 @@ class ZWaveServices: ), ) + self._hass.services.async_register( + const.DOMAIN, + const.SERVICE_REFRESH_NOTIFICATIONS, + self.async_refresh_notifications, + schema=vol.Schema( + vol.All( + { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(ATTR_DEVICE_ID): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(const.ATTR_NOTIFICATION_TYPE): vol.All( + vol.Coerce(int), vol.Coerce(NotificationType) + ), + vol.Optional(const.ATTR_NOTIFICATION_EVENT): vol.Coerce(int), + }, + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), + get_nodes_from_service_data, + has_at_least_one_node, + ), + ), + ) + async def async_set_config_parameter(self, service: ServiceCall) -> None: """Set a config value on a node.""" nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] @@ -643,38 +717,14 @@ class ZWaveServices: method_name: str = service.data[const.ATTR_METHOD_NAME] parameters: list[Any] = service.data[const.ATTR_PARAMETERS] - async def _async_invoke_cc_api(endpoints: set[Endpoint]) -> None: - """Invoke the CC API on a node endpoint.""" - results = await asyncio.gather( - *( - endpoint.async_invoke_cc_api( - command_class, method_name, *parameters - ) - for endpoint in endpoints - ), - return_exceptions=True, - ) - endpoints_list = list(endpoints) - for endpoint, result in get_valid_responses_from_results( - endpoints_list, results - ): - _LOGGER.info( - ( - "Invoked %s CC API method %s on endpoint %s with the following " - "result: %s" - ), - command_class.name, - method_name, - endpoint, - result, - ) - raise_exceptions_from_results(endpoints_list, results) - # If an endpoint is provided, we assume the user wants to call the CC API on # that endpoint for all target nodes if (endpoint := service.data.get(const.ATTR_ENDPOINT)) is not None: await _async_invoke_cc_api( - {node.endpoints[endpoint] for node in service.data[const.ATTR_NODES]} + {node.endpoints[endpoint] for node in service.data[const.ATTR_NODES]}, + command_class, + method_name, + *parameters, ) return @@ -723,4 +773,14 @@ class ZWaveServices: node.endpoints[endpoint_idx if endpoint_idx is not None else 0] ) - await _async_invoke_cc_api(endpoints) + await _async_invoke_cc_api(endpoints, command_class, method_name, *parameters) + + async def async_refresh_notifications(self, service: ServiceCall) -> None: + """Refresh notifications on a node.""" + nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] + notification_type: NotificationType = service.data[const.ATTR_NOTIFICATION_TYPE] + notification_event: int | None = service.data.get(const.ATTR_NOTIFICATION_EVENT) + param: dict[str, int] = {"notificationType": notification_type.value} + if notification_event is not None: + param["notificationEvent"] = notification_event + await _async_invoke_cc_api(nodes, CommandClass.NOTIFICATION, "get", param) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index e3d59ff43f7..e21103aa22e 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -223,3 +223,25 @@ invoke_cc_api: required: true selector: object: + +refresh_notifications: + target: + entity: + integration: zwave_js + fields: + notification_type: + example: 1 + required: true + selector: + number: + min: 1 + max: 22 + mode: box + notification_event: + example: 1 + required: false + selector: + number: + min: 1 + max: 255 + mode: box diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 4bb9494eb6b..59cec0ed541 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -363,6 +363,20 @@ "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters." } } + }, + "refresh_notifications": { + "name": "Refresh notifications on a node (advanced)", + "description": "Refreshes notifications on a node based on notification type and optionally notification event.", + "fields": { + "notification_type": { + "name": "Notification Type", + "description": "The Notification Type number as defined in the Z-Wave specs." + }, + "notification_event": { + "name": "Notification Event", + "description": "The Notification Event number as defined in the Z-Wave specs." + } + } } } } diff --git a/tests/components/zwave_js/fixtures/multisensor_6_state.json b/tests/components/zwave_js/fixtures/multisensor_6_state.json index 580393ae6cd..5dc34c2f3ac 100644 --- a/tests/components/zwave_js/fixtures/multisensor_6_state.json +++ b/tests/components/zwave_js/fixtures/multisensor_6_state.json @@ -62,7 +62,14 @@ "index": 0, "installerIcon": 3079, "userIcon": 3079, - "commandClasses": [] + "commandClasses": [ + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": false + } + ] } ], "values": [ diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 84d9b457d18..f5b7809d8cc 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -14,6 +14,8 @@ from homeassistant.components.zwave_js.const import ( ATTR_CONFIG_VALUE, ATTR_ENDPOINT, ATTR_METHOD_NAME, + ATTR_NOTIFICATION_EVENT, + ATTR_NOTIFICATION_TYPE, ATTR_OPTIONS, ATTR_PARAMETERS, ATTR_PROPERTY, @@ -26,6 +28,7 @@ from homeassistant.components.zwave_js.const import ( SERVICE_INVOKE_CC_API, SERVICE_MULTICAST_SET_VALUE, SERVICE_PING, + SERVICE_REFRESH_NOTIFICATIONS, SERVICE_REFRESH_VALUE, SERVICE_SET_CONFIG_PARAMETER, SERVICE_SET_VALUE, @@ -1777,3 +1780,97 @@ async def test_invoke_cc_api( client.async_send_command.reset_mock() client.async_send_command_no_wait.reset_mock() + + +async def test_refresh_notifications( + hass: HomeAssistant, client, zen_31, multisensor_6, integration +) -> None: + """Test refresh_notifications service.""" + dev_reg = async_get_dev_reg(hass) + zen_31_device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, zen_31)} + ) + assert zen_31_device + multisensor_6_device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, multisensor_6)} + ) + assert multisensor_6_device + + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(zen_31_device.id, area_id=area.id) + + # Test successful refresh_notifications call + client.async_send_command.return_value = {"response": True} + client.async_send_command_no_wait.return_value = {"response": True} + + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_NOTIFICATIONS, + { + ATTR_AREA_ID: area.id, + ATTR_DEVICE_ID: [zen_31_device.id, multisensor_6_device.id], + ATTR_NOTIFICATION_TYPE: 1, + ATTR_NOTIFICATION_EVENT: 2, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 113 + assert args["endpoint"] == 0 + assert args["methodName"] == "get" + assert args["args"] == [{"notificationType": 1, "notificationEvent": 2}] + assert args["nodeId"] == zen_31.node_id + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 113 + assert args["endpoint"] == 0 + assert args["methodName"] == "get" + assert args["args"] == [{"notificationType": 1, "notificationEvent": 2}] + assert args["nodeId"] == multisensor_6.node_id + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() + + # Test failed refresh_notifications call on one node. We return the error on + # the first node in the call to make sure that gather works as expected + client.async_send_command.return_value = {"response": True} + client.async_send_command_no_wait.side_effect = FailedZWaveCommand( + "test", 12, "test" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_NOTIFICATIONS, + { + ATTR_DEVICE_ID: [multisensor_6_device.id, zen_31_device.id], + ATTR_NOTIFICATION_TYPE: 1, + }, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 113 + assert args["endpoint"] == 0 + assert args["methodName"] == "get" + assert args["args"] == [{"notificationType": 1}] + assert args["nodeId"] == zen_31.node_id + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 113 + assert args["endpoint"] == 0 + assert args["methodName"] == "get" + assert args["args"] == [{"notificationType": 1}] + assert args["nodeId"] == multisensor_6.node_id + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() From 6276c4483ce61f6514f14e3803385c8158c0d0d1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 7 Nov 2023 16:33:46 +0100 Subject: [PATCH 298/982] Raise exception when data can't be fetched in Opensky (#103596) --- homeassistant/components/opensky/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index cb9c6173694..6e60c2ec4f1 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from aiohttp import BasicAuth from python_opensky import OpenSky -from python_opensky.exceptions import OpenSkyUnauthenticatedError +from python_opensky.exceptions import OpenSkyError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), contributing_user=entry.options.get(CONF_CONTRIBUTING_USER, False), ) - except OpenSkyUnauthenticatedError as exc: + except OpenSkyError as exc: raise ConfigEntryNotReady from exc coordinator = OpenSkyDataUpdateCoordinator(hass, client) From 05deae09fc5f6a81a4fa2783ea7731fa24440a6b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Nov 2023 17:10:15 +0100 Subject: [PATCH 299/982] Add file and line annotation to strings when loading yaml (#103586) --- homeassistant/util/yaml/dumper.py | 7 ++++++- homeassistant/util/yaml/loader.py | 11 +++++++++++ tests/util/yaml/test_init.py | 33 +++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index a3fba653042..65747d1fd3e 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -4,7 +4,7 @@ from typing import Any import yaml -from .objects import Input, NodeDictClass, NodeListClass +from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass # mypy: allow-untyped-calls, no-warn-return-any @@ -84,6 +84,11 @@ add_representer( lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), ) +add_representer( + NodeStrClass, + lambda dumper, value: dumper.represent_scalar("tag:yaml.org,2002:str", str(value)), +) + add_representer( Input, lambda dumper, value: dumper.represent_scalar("!input", value.name), diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 5f18a729130..75942e5ea79 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -371,6 +371,16 @@ def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: return _add_reference(obj, loader, node) +def _handle_scalar_tag( + loader: LoaderType, node: yaml.nodes.ScalarNode +) -> str | int | float | None: + """Add line number and file name to Load YAML sequence.""" + obj = loader.construct_scalar(node) + if not isinstance(obj, str): + return obj + return _add_reference(obj, loader, node) + + def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str: """Load environment variables and embed it into the configuration YAML.""" args = node.value.split() @@ -400,6 +410,7 @@ def add_constructor(tag: Any, constructor: Any) -> None: add_constructor("!include", _include_yaml) add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _handle_mapping_tag) +add_constructor(yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, _handle_scalar_tag) add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) add_constructor("!env_var", _env_var_yaml) add_constructor("!secret", secret_yaml) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 4f60c5836b5..53b3143ac0b 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -547,3 +547,36 @@ async def test_loading_actual_file_with_syntax( "fixtures", "bad.yaml.txt" ) await hass.async_add_executor_job(load_yaml_config_file, fixture_path) + + +def test_string_annotated(try_both_loaders) -> None: + """Test strings are annotated with file + line.""" + conf = ( + "key1: str\n" + "key2:\n" + " blah: blah\n" + "key3:\n" + " - 1\n" + " - 2\n" + " - 3\n" + "key4: yes\n" + "key5: 1\n" + "key6: 1.0\n" + ) + expected_annotations = { + "key1": [("", 0), ("", 0)], + "key2": [("", 1), ("", 2)], + "key3": [("", 3), ("", 4)], + "key4": [("", 7), (None, None)], + "key5": [("", 8), (None, None)], + "key6": [("", 9), (None, None)], + } + with io.StringIO(conf) as file: + doc = yaml_loader.parse_yaml(file) + for key, value in doc.items(): + assert getattr(key, "__config_file__", None) == expected_annotations[key][0][0] + assert getattr(key, "__line__", None) == expected_annotations[key][0][1] + assert ( + getattr(value, "__config_file__", None) == expected_annotations[key][1][0] + ) + assert getattr(value, "__line__", None) == expected_annotations[key][1][1] From d3ed8a6b8b2e839a780488c6e67fcd1eebf4d211 Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Tue, 7 Nov 2023 09:56:24 -0800 Subject: [PATCH 300/982] Validate empty sentence triggers (#103579) * Validate empty sentence triggers * Add extra test for no sentences * Remove extra line --------- Co-authored-by: Michael Hansen --- .../components/conversation/trigger.py | 14 +++++++- tests/components/conversation/test_trigger.py | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 71ddb5c1237..30cc9a0d5d0 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -26,11 +26,23 @@ def has_no_punctuation(value: list[str]) -> list[str]: return value +def has_one_non_empty_item(value: list[str]) -> list[str]: + """Validate result has at least one item.""" + if len(value) < 1: + raise vol.Invalid("at least one sentence is required") + + for sentence in value: + if not sentence: + raise vol.Invalid(f"sentence too short: '{sentence}'") + + return value + + TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): DOMAIN, vol.Required(CONF_COMMAND): vol.All( - cv.ensure_list, [cv.string], has_no_punctuation + cv.ensure_list, [cv.string], has_one_non_empty_item, has_no_punctuation ), } ) diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 3f4dd9e3a7e..4fe9fed6bb2 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -194,6 +194,42 @@ async def test_fails_on_punctuation(hass: HomeAssistant, command: str) -> None: ) +@pytest.mark.parametrize( + "command", + [""], +) +async def test_fails_on_empty(hass: HomeAssistant, command: str) -> None: + """Test that validation fails when sentences are empty.""" + with pytest.raises(vol.Invalid): + await trigger.async_validate_trigger_config( + hass, + [ + { + "id": "trigger1", + "platform": "conversation", + "command": [ + command, + ], + }, + ], + ) + + +async def test_fails_on_no_sentences(hass: HomeAssistant) -> None: + """Test that validation fails when no sentences are provided.""" + with pytest.raises(vol.Invalid): + await trigger.async_validate_trigger_config( + hass, + [ + { + "id": "trigger1", + "platform": "conversation", + "command": [], + }, + ], + ) + + async def test_wildcards(hass: HomeAssistant, calls, setup_comp) -> None: """Test wildcards in trigger sentences.""" assert await async_setup_component( From 0d63e2f9b5262d139a9768618e8d6699083c11e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Nov 2023 12:37:54 -0600 Subject: [PATCH 301/982] Ensure large payloads are compressed in the executor with aiohttp 3.9.0 (#103592) --- homeassistant/components/api/__init__.py | 3 ++- homeassistant/components/hassio/ingress.py | 3 ++- homeassistant/components/http/view.py | 3 ++- homeassistant/helpers/aiohttp_compat.py | 18 +++++++++++++++++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a9efda90482..6bb3cc34050 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -41,6 +41,7 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions @@ -219,7 +220,7 @@ class APIStatesView(HomeAssistantView): response = web.Response( body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON ) - response.enable_compression() + enable_compression(response) return response diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 345c14163f5..b29f80ff2b3 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -17,6 +17,7 @@ from yarl import URL from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.typing import UNDEFINED from .const import X_HASS_SOURCE, X_INGRESS_PATH @@ -191,7 +192,7 @@ class HassIOIngress(HomeAssistantView): if content_length_int > MIN_COMPRESSED_SIZE and should_compress( content_type or simple_response.content_type ): - simple_response.enable_compression() + enable_compression(simple_response) await simple_response.prepare(request) return simple_response diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index abdcfe466c1..7481381bbc8 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -20,6 +20,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, is_callback +from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.json import ( find_paths_unserializable_data, json_bytes, @@ -72,7 +73,7 @@ class HomeAssistantView: status=int(status_code), headers=headers, ) - response.enable_compression() + enable_compression(response) return response def json_message( diff --git a/homeassistant/helpers/aiohttp_compat.py b/homeassistant/helpers/aiohttp_compat.py index 78aad44fa66..6e281b659fe 100644 --- a/homeassistant/helpers/aiohttp_compat.py +++ b/homeassistant/helpers/aiohttp_compat.py @@ -1,7 +1,7 @@ """Helper to restore old aiohttp behavior.""" from __future__ import annotations -from aiohttp import web_protocol, web_server +from aiohttp import web, web_protocol, web_server class CancelOnDisconnectRequestHandler(web_protocol.RequestHandler): @@ -23,3 +23,19 @@ def restore_original_aiohttp_cancel_behavior() -> None: """ web_protocol.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] web_server.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] + + +def enable_compression(response: web.Response) -> None: + """Enable compression on the response.""" + # + # Set _zlib_executor_size in the constructor once support for + # aiohttp < 3.9.0 is dropped + # + # We want large zlib payloads to be compressed in the executor + # to avoid blocking the event loop. + # + # 32KiB was chosen based on testing in production. + # aiohttp will generate a warning for payloads larger than 1MiB + # + response._zlib_executor_size = 32768 # pylint: disable=protected-access + response.enable_compression() From 9c2febc72eabe31deb08fe6672c27fea4719c123 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 7 Nov 2023 13:52:58 -0600 Subject: [PATCH 302/982] Small code clean up (#103603) --- homeassistant/components/intent/__init__.py | 18 ++++++++++++------ homeassistant/components/tts/__init__.py | 3 ++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 4d256795f55..306f169106b 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -79,13 +79,16 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): if state.domain == COVER_DOMAIN: # on = open # off = close + if self.service == SERVICE_TURN_ON: + service_name = SERVICE_OPEN_COVER + else: + service_name = SERVICE_CLOSE_COVER + await self._run_then_background( hass.async_create_task( hass.services.async_call( COVER_DOMAIN, - SERVICE_OPEN_COVER - if self.service == SERVICE_TURN_ON - else SERVICE_CLOSE_COVER, + service_name, {ATTR_ENTITY_ID: state.entity_id}, context=intent_obj.context, blocking=True, @@ -97,13 +100,16 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): if state.domain == LOCK_DOMAIN: # on = lock # off = unlock + if self.service == SERVICE_TURN_ON: + service_name = SERVICE_LOCK + else: + service_name = SERVICE_UNLOCK + await self._run_then_background( hass.async_create_task( hass.services.async_call( LOCK_DOMAIN, - SERVICE_LOCK - if self.service == SERVICE_TURN_ON - else SERVICE_UNLOCK, + service_name, {ATTR_ENTITY_ID: state.entity_id}, context=intent_obj.context, blocking=True, diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index f84c819e739..38715825875 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -278,7 +278,8 @@ def _convert_audio( if proc.returncode != 0: _LOGGER.error(stderr.decode()) raise RuntimeError( - f"Unexpected error while running ffmpeg with arguments: {command}. See log for details." + f"Unexpected error while running ffmpeg with arguments: {command}." + "See log for details." ) output_file.seek(0) From 0d67557106f3608c40ce341cb3f4baa229b7cfc0 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 7 Nov 2023 20:53:22 +0000 Subject: [PATCH 303/982] Add V2C Trydan EVSE integration (#103478) Co-authored-by: Joost Lekkerkerker --- .coveragerc | 4 + CODEOWNERS | 2 + homeassistant/components/v2c/__init__.py | 38 +++++++++ homeassistant/components/v2c/config_flow.py | 57 +++++++++++++ homeassistant/components/v2c/const.py | 3 + homeassistant/components/v2c/coordinator.py | 41 +++++++++ homeassistant/components/v2c/entity.py | 41 +++++++++ homeassistant/components/v2c/manifest.json | 9 ++ homeassistant/components/v2c/sensor.py | 93 +++++++++++++++++++++ homeassistant/components/v2c/strings.json | 25 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/v2c/__init__.py | 1 + tests/components/v2c/conftest.py | 14 ++++ tests/components/v2c/test_config_flow.py | 86 +++++++++++++++++++ 17 files changed, 427 insertions(+) create mode 100644 homeassistant/components/v2c/__init__.py create mode 100644 homeassistant/components/v2c/config_flow.py create mode 100644 homeassistant/components/v2c/const.py create mode 100644 homeassistant/components/v2c/coordinator.py create mode 100644 homeassistant/components/v2c/entity.py create mode 100644 homeassistant/components/v2c/manifest.json create mode 100644 homeassistant/components/v2c/sensor.py create mode 100644 homeassistant/components/v2c/strings.json create mode 100644 tests/components/v2c/__init__.py create mode 100644 tests/components/v2c/conftest.py create mode 100644 tests/components/v2c/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 99685df79d7..0bd6d40ac34 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1430,6 +1430,10 @@ omit = homeassistant/components/upnp/device.py homeassistant/components/upnp/sensor.py homeassistant/components/vasttrafik/sensor.py + homeassistant/components/v2c/__init__.py + homeassistant/components/v2c/coordinator.py + homeassistant/components/v2c/entity.py + homeassistant/components/v2c/sensor.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py homeassistant/components/velbus/button.py diff --git a/CODEOWNERS b/CODEOWNERS index 0a594d71b77..0381cb9aec6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1373,6 +1373,8 @@ build.json @home-assistant/supervisor /tests/components/usgs_earthquakes_feed/ @exxamalte /homeassistant/components/utility_meter/ @dgomes /tests/components/utility_meter/ @dgomes +/homeassistant/components/v2c/ @dgomes +/tests/components/v2c/ @dgomes /homeassistant/components/vacuum/ @home-assistant/core /tests/components/vacuum/ @home-assistant/core /homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py new file mode 100644 index 00000000000..030ae56bb79 --- /dev/null +++ b/homeassistant/components/v2c/__init__.py @@ -0,0 +1,38 @@ +"""The V2C integration.""" +from __future__ import annotations + +from pytrydan import Trydan + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up V2C from a config entry.""" + + host = entry.data[CONF_HOST] + trydan = Trydan(host, get_async_client(hass, verify_ssl=False)) + coordinator = V2CUpdateCoordinator(hass, trydan, host) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/v2c/config_flow.py b/homeassistant/components/v2c/config_flow.py new file mode 100644 index 00000000000..382b41d3994 --- /dev/null +++ b/homeassistant/components/v2c/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for V2C integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pytrydan import Trydan +from pytrydan.exceptions import TrydanError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for V2C.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + evse = Trydan( + user_input[CONF_HOST], + client=get_async_client(self.hass, verify_ssl=False), + ) + + try: + await evse.get_data() + except TrydanError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"EVSE {user_input[CONF_HOST]}", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/v2c/const.py b/homeassistant/components/v2c/const.py new file mode 100644 index 00000000000..b568368f718 --- /dev/null +++ b/homeassistant/components/v2c/const.py @@ -0,0 +1,3 @@ +"""Constants for the V2C integration.""" + +DOMAIN = "v2c" diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py new file mode 100644 index 00000000000..b2db66f1b80 --- /dev/null +++ b/homeassistant/components/v2c/coordinator.py @@ -0,0 +1,41 @@ +"""The v2c component.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pytrydan import Trydan, TrydanData +from pytrydan.exceptions import TrydanError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +class V2CUpdateCoordinator(DataUpdateCoordinator[TrydanData]): + """DataUpdateCoordinator to gather data from any v2c.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, evse: Trydan, host: str) -> None: + """Initialize DataUpdateCoordinator for a v2c evse.""" + self.evse = evse + super().__init__( + hass, + _LOGGER, + name=f"EVSE {host}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> TrydanData: + """Fetch sensor data from api.""" + try: + data: TrydanData = await self.evse.get_data() + _LOGGER.debug("Received data: %s", data) + return data + except TrydanError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/v2c/entity.py b/homeassistant/components/v2c/entity.py new file mode 100644 index 00000000000..c00e221d397 --- /dev/null +++ b/homeassistant/components/v2c/entity.py @@ -0,0 +1,41 @@ +"""Support for V2C EVSE.""" +from __future__ import annotations + +from pytrydan import TrydanData + +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 V2CUpdateCoordinator + + +class V2CBaseEntity(CoordinatorEntity[V2CUpdateCoordinator]): + """Defines a base v2c entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Init the V2C base entity.""" + self.entity_description = description + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.evse.host)}, + manufacturer="V2C", + model="Trydan", + name=coordinator.name, + sw_version=coordinator.evse.firmware_version, + ) + + @property + def data(self) -> TrydanData: + """Return v2c evse data.""" + data = self.coordinator.data + assert data is not None + return data diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json new file mode 100644 index 00000000000..ce81f3e1424 --- /dev/null +++ b/homeassistant/components/v2c/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "v2c", + "name": "V2C", + "codeowners": ["@dgomes"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/v2c", + "iot_class": "local_polling", + "requirements": ["pytrydan==0.1.2"] +} diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py new file mode 100644 index 00000000000..60ef582ce8d --- /dev/null +++ b/homeassistant/components/v2c/sensor.py @@ -0,0 +1,93 @@ +"""Support for V2C EVSE sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pytrydan import TrydanData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator +from .entity import V2CBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class V2CPowerRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TrydanData], float] + + +@dataclass +class V2CPowerSensorEntityDescription( + SensorEntityDescription, V2CPowerRequiredKeysMixin +): + """Describes an EVSE Power sensor entity.""" + + +POWER_SENSORS = ( + V2CPowerSensorEntityDescription( + key="charge_power", + translation_key="charge_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.charge_power, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C sensor platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[Entity] = [ + V2CPowerSensorEntity(coordinator, description, config_entry.entry_id) + for description in POWER_SENSORS + ] + async_add_entities(entities) + + +class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): + """Defines a base v2c sensor entity.""" + + +class V2CPowerSensorEntity(V2CSensorBaseEntity): + """V2C Power sensor entity.""" + + entity_description: V2CPowerSensorEntityDescription + _attr_icon = "mdi:ev-station" + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: SensorEntityDescription, + entry_id: str, + ) -> None: + """Initialize V2C Power entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json new file mode 100644 index 00000000000..3a87f91ebc5 --- /dev/null +++ b/homeassistant/components/v2c/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "charge_power": { + "name": "Charge power" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4cd6a93d83a..b0327dbdc29 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -513,6 +513,7 @@ FLOWS = { "upnp", "uptime", "uptimerobot", + "v2c", "vallox", "velbus", "venstar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 475a1c47eb2..e6ceda10924 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6149,6 +6149,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "v2c": { + "name": "V2C", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "vallox": { "name": "Vallox", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index e966e0871f7..9c5b2e5d28c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2225,6 +2225,9 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_weatherstation pytrafikverket==0.3.8 +# homeassistant.components.v2c +pytrydan==0.1.2 + # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d469ed5038d..b251418a0cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1660,6 +1660,9 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_weatherstation pytrafikverket==0.3.8 +# homeassistant.components.v2c +pytrydan==0.1.2 + # homeassistant.components.usb pyudev==0.23.2 diff --git a/tests/components/v2c/__init__.py b/tests/components/v2c/__init__.py new file mode 100644 index 00000000000..fdb29e58644 --- /dev/null +++ b/tests/components/v2c/__init__.py @@ -0,0 +1 @@ +"""Tests for the V2C integration.""" diff --git a/tests/components/v2c/conftest.py b/tests/components/v2c/conftest.py new file mode 100644 index 00000000000..85831b607b7 --- /dev/null +++ b/tests/components/v2c/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the V2C tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.v2c.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/v2c/test_config_flow.py b/tests/components/v2c/test_config_flow.py new file mode 100644 index 00000000000..0124c1abb9c --- /dev/null +++ b/tests/components/v2c/test_config_flow.py @@ -0,0 +1,86 @@ +"""Test the V2C config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest +from pytrydan.exceptions import TrydanError + +from homeassistant import config_entries +from homeassistant.components.v2c.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "pytrydan.Trydan.get_data", + return_value={}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "EVSE 1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TrydanError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pytrydan.Trydan.get_data", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} + + with patch( + "pytrydan.Trydan.get_data", + return_value={}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "EVSE 1.1.1.1" + assert result3["data"] == { + "host": "1.1.1.1", + } From 624837912c56b4b6d28b25bca70182b935c2305e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 7 Nov 2023 22:11:02 +0100 Subject: [PATCH 304/982] Fix metoffice test_forecast_subscription raises key error (#103598) Allow test time change to be processed --- tests/components/metoffice/test_weather.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 6c6041b1869..8930d318ec7 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -559,6 +559,7 @@ async def test_forecast_subscription( assert forecast1 == snapshot freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() msg = await client.receive_json() @@ -575,5 +576,8 @@ async def test_forecast_subscription( "subscription": subscription_id, } ) + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() msg = await client.receive_json() assert msg["success"] From 836ebfd84b82c1d5a4229f2ab869c372b719ab7c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 7 Nov 2023 22:50:09 +0100 Subject: [PATCH 305/982] Bump yt-dlp to 2023.10.13 (#103616) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 37a8a0d6773..d16439800a9 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2023.9.24"] + "requirements": ["yt-dlp==2023.10.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c5b2e5d28c..937853b3318 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2785,7 +2785,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.9.24 +yt-dlp==2023.10.13 # homeassistant.components.zamg zamg==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b251418a0cf..331a099f1ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2082,7 +2082,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.9.24 +yt-dlp==2023.10.13 # homeassistant.components.zamg zamg==0.3.0 From 95fe7aa491c5fdc0d925a5b3c75d886213895562 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 7 Nov 2023 22:54:56 +0100 Subject: [PATCH 306/982] Update open-meteo to v0.3.1 (#103613) --- homeassistant/components/open_meteo/diagnostics.py | 5 +---- homeassistant/components/open_meteo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/open_meteo/conftest.py | 2 +- 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/open_meteo/diagnostics.py b/homeassistant/components/open_meteo/diagnostics.py index a429b0c368f..a88325066fe 100644 --- a/homeassistant/components/open_meteo/diagnostics.py +++ b/homeassistant/components/open_meteo/diagnostics.py @@ -1,7 +1,6 @@ """Diagnostics support for Open-Meteo.""" from __future__ import annotations -import json from typing import Any from open_meteo import Forecast @@ -25,6 +24,4 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: DataUpdateCoordinator[Forecast] = hass.data[DOMAIN][entry.entry_id] - # Round-trip via JSON to trigger serialization - data: dict[str, Any] = json.loads(coordinator.data.json()) - return async_redact_data(data, TO_REDACT) + return async_redact_data(coordinator.data.to_dict(), TO_REDACT) diff --git a/homeassistant/components/open_meteo/manifest.json b/homeassistant/components/open_meteo/manifest.json index 1819a1deaa8..abdb59a48d0 100644 --- a/homeassistant/components/open_meteo/manifest.json +++ b/homeassistant/components/open_meteo/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/open_meteo", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["open-meteo==0.2.1"] + "requirements": ["open-meteo==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 937853b3318..4ac5f5b1b0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1370,7 +1370,7 @@ onvif-zeep-async==3.1.12 open-garage==0.2.0 # homeassistant.components.open_meteo -open-meteo==0.2.1 +open-meteo==0.3.1 # homeassistant.components.openai_conversation openai==0.27.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 331a099f1ba..fa92508a1e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1066,7 +1066,7 @@ onvif-zeep-async==3.1.12 open-garage==0.2.0 # homeassistant.components.open_meteo -open-meteo==0.2.1 +open-meteo==0.3.1 # homeassistant.components.openai_conversation openai==0.27.2 diff --git a/tests/components/open_meteo/conftest.py b/tests/components/open_meteo/conftest.py index cb950dcc442..76bb3039a2f 100644 --- a/tests/components/open_meteo/conftest.py +++ b/tests/components/open_meteo/conftest.py @@ -40,7 +40,7 @@ def mock_open_meteo(request: pytest.FixtureRequest) -> Generator[None, MagicMock if hasattr(request, "param") and request.param: fixture = request.param - forecast = Forecast.parse_raw(load_fixture(fixture, DOMAIN)) + forecast = Forecast.from_json(load_fixture(fixture, DOMAIN)) with patch( "homeassistant.components.open_meteo.OpenMeteo", autospec=True ) as open_meteo_mock: From c29b0cd05b70c2b5c13b71f4378c15a210273b25 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Nov 2023 23:22:23 +0100 Subject: [PATCH 307/982] Correct line numbers in yaml node annotations (#103605) --- homeassistant/util/yaml/loader.py | 2 +- tests/util/yaml/test_init.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 75942e5ea79..73e7861902f 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -240,7 +240,7 @@ def _add_reference( # type: ignore[no-untyped-def] if isinstance(obj, str): obj = NodeStrClass(obj) setattr(obj, "__config_file__", loader.get_name()) - setattr(obj, "__line__", node.start_mark.line) + setattr(obj, "__line__", node.start_mark.line + 1) return obj diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 53b3143ac0b..315f6618e89 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -564,12 +564,12 @@ def test_string_annotated(try_both_loaders) -> None: "key6: 1.0\n" ) expected_annotations = { - "key1": [("", 0), ("", 0)], - "key2": [("", 1), ("", 2)], - "key3": [("", 3), ("", 4)], - "key4": [("", 7), (None, None)], - "key5": [("", 8), (None, None)], - "key6": [("", 9), (None, None)], + "key1": [("", 1), ("", 1)], + "key2": [("", 2), ("", 3)], + "key3": [("", 4), ("", 5)], + "key4": [("", 8), (None, None)], + "key5": [("", 9), (None, None)], + "key6": [("", 10), (None, None)], } with io.StringIO(conf) as file: doc = yaml_loader.parse_yaml(file) From e8c568a243cd0bc38d61045c10ffc608cf4b0f7d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Nov 2023 14:24:34 -0800 Subject: [PATCH 308/982] Remove rainbird yaml config test fixtures (#103607) --- tests/components/rainbird/conftest.py | 27 +++-------- .../components/rainbird/test_binary_sensor.py | 23 +++++----- tests/components/rainbird/test_calendar.py | 40 ++++++++--------- tests/components/rainbird/test_config_flow.py | 20 +++------ tests/components/rainbird/test_init.py | 45 +++++++------------ tests/components/rainbird/test_number.py | 28 +++++------- tests/components/rainbird/test_sensor.py | 20 ++++++--- tests/components/rainbird/test_switch.py | 43 ++++++------------ 8 files changed, 97 insertions(+), 149 deletions(-) diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index f25bdfb1d86..6e8d58219c1 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -17,13 +16,10 @@ from homeassistant.components.rainbird.const import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse -ComponentSetup = Callable[[], Awaitable[bool]] - HOST = "example.com" URL = "http://example.com/stick" PASSWORD = "password" @@ -79,12 +75,6 @@ def platforms() -> list[Platform]: return [] -@pytest.fixture -def yaml_config() -> dict[str, Any]: - """Fixture for configuration.yaml.""" - return {} - - @pytest.fixture async def config_entry_unique_id() -> str: """Fixture for serial number used in the config entry.""" @@ -122,22 +112,15 @@ async def add_config_entry( config_entry.add_to_hass(hass) -@pytest.fixture -async def setup_integration( +@pytest.fixture(autouse=True) +def setup_platforms( hass: HomeAssistant, platforms: list[str], - yaml_config: dict[str, Any], -) -> Generator[ComponentSetup, None, None]: - """Fixture for setting up the component.""" +) -> None: + """Fixture for setting up the default platforms.""" with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): - - async def func() -> bool: - result = await async_setup_component(hass, DOMAIN, yaml_config) - await hass.async_block_till_done() - return result - - yield func + yield def rainbird_response(data: str) -> bytes: diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index 24cd1750ed4..7b9fb41ed1f 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -3,12 +3,14 @@ import pytest +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, SERIAL_NUMBER, ComponentSetup +from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, SERIAL_NUMBER +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -18,21 +20,27 @@ def platforms() -> list[Platform]: return [Platform.BINARY_SENSOR] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.mark.parametrize( ("rain_response", "expected_state"), [(RAIN_SENSOR_OFF, "off"), (RAIN_SENSOR_ON, "on")], ) async def test_rainsensor( hass: HomeAssistant, - setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, expected_state: bool, ) -> None: """Test rainsensor binary sensor.""" - assert await setup_integration() - rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert rainsensor.state == expected_state @@ -53,14 +61,10 @@ async def test_rainsensor( ) async def test_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, entity_unique_id: str, ) -> None: """Test rainsensor binary sensor.""" - - assert await setup_integration() - rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert rainsensor.attributes == { @@ -83,14 +87,11 @@ async def test_unique_id( ) async def test_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, ) -> None: """Test rainsensor binary sensor with no unique id.""" - assert await setup_integration() - rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert ( diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 2e486226a7b..d6c14834342 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -12,12 +12,14 @@ from aiohttp import ClientSession from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ComponentSetup, mock_response, mock_response_error +from .conftest import mock_response, mock_response_error +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse TEST_ENTITY = "calendar.rain_bird_controller" @@ -80,6 +82,15 @@ def platforms() -> list[str]: return [Platform.CALENDAR] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" @@ -121,13 +132,9 @@ def get_events_fixture( @pytest.mark.freeze_time("2023-01-21 09:32:00") -async def test_get_events( - hass: HomeAssistant, setup_integration: ComponentSetup, get_events: GetEventsFn -) -> None: +async def test_get_events(hass: HomeAssistant, get_events: GetEventsFn) -> None: """Test calendar event fetching APIs.""" - assert await setup_integration() - events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") assert events == [ # Monday @@ -158,31 +165,34 @@ async def test_get_events( @pytest.mark.parametrize( - ("freeze_time", "expected_state"), + ("freeze_time", "expected_state", "setup_config_entry"), [ ( datetime.datetime(2023, 1, 23, 3, 50, tzinfo=ZoneInfo("America/Regina")), "off", + None, ), ( datetime.datetime(2023, 1, 23, 4, 30, tzinfo=ZoneInfo("America/Regina")), "on", + None, ), ], ) async def test_event_state( hass: HomeAssistant, - setup_integration: ComponentSetup, get_events: GetEventsFn, freezer: FrozenDateTimeFactory, freeze_time: datetime.datetime, expected_state: str, entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test calendar upcoming event state.""" freezer.move_to(freeze_time) - assert await setup_integration() + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get(TEST_ENTITY) assert state is not None @@ -213,13 +223,10 @@ async def test_event_state( ) async def test_calendar_not_supported_by_device( hass: HomeAssistant, - setup_integration: ComponentSetup, has_entity: bool, ) -> None: """Test calendar upcoming event state.""" - assert await setup_integration() - state = hass.states.get(TEST_ENTITY) assert (state is not None) == has_entity @@ -229,7 +236,6 @@ async def test_calendar_not_supported_by_device( ) async def test_no_schedule( hass: HomeAssistant, - setup_integration: ComponentSetup, get_events: GetEventsFn, responses: list[AiohttpClientMockResponse], hass_client: Callable[..., Awaitable[ClientSession]], @@ -237,8 +243,6 @@ async def test_no_schedule( """Test calendar error when fetching the calendar.""" responses.extend([mock_response_error(HTTPStatus.BAD_GATEWAY)]) # Arbitrary error - assert await setup_integration() - state = hass.states.get(TEST_ENTITY) assert state.state == "unavailable" assert state.attributes == { @@ -260,13 +264,10 @@ async def test_no_schedule( ) async def test_program_schedule_disabled( hass: HomeAssistant, - setup_integration: ComponentSetup, get_events: GetEventsFn, ) -> None: """Test calendar when the program is disabled with no upcoming events.""" - assert await setup_integration() - events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") assert events == [] @@ -286,14 +287,11 @@ async def test_program_schedule_disabled( ) async def test_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, get_events: GetEventsFn, entity_registry: er.EntityRegistry, ) -> None: """Test calendar entity with no unique id.""" - assert await setup_integration() - state = hass.states.get(TEST_ENTITY) assert state is not None assert state.attributes.get("friendly_name") == "Rain Bird Controller" diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index cfc4ff3b5cb..f93da8d9839 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -24,10 +24,10 @@ from .conftest import ( SERIAL_RESPONSE, URL, ZERO_SERIAL_RESPONSE, - ComponentSetup, mock_response, ) +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @@ -129,17 +129,14 @@ async def test_controller_flow( ) async def test_multiple_config_entries( hass: HomeAssistant, - setup_integration: ComponentSetup, + config_entry: MockConfigEntry, responses: list[AiohttpClientMockResponse], config_flow_responses: list[AiohttpClientMockResponse], expected_config_entry: dict[str, Any] | None, ) -> None: """Test setting up multiple config entries that refer to different devices.""" - assert await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state == ConfigEntryState.LOADED + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED responses.clear() responses.extend(config_flow_responses) @@ -177,16 +174,13 @@ async def test_multiple_config_entries( ) async def test_duplicate_config_entries( hass: HomeAssistant, - setup_integration: ComponentSetup, + config_entry: MockConfigEntry, responses: list[AiohttpClientMockResponse], config_flow_responses: list[AiohttpClientMockResponse], ) -> None: """Test that a device can not be registered twice.""" - assert await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state == ConfigEntryState.LOADED + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED responses.clear() responses.extend(config_flow_responses) diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index f548d3aacda..7ec22b88867 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -6,31 +6,30 @@ from http import HTTPStatus import pytest -from homeassistant.components.rainbird import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .conftest import ( CONFIG_ENTRY_DATA, MODEL_AND_VERSION_RESPONSE, - ComponentSetup, mock_response, mock_response_error, ) +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse @pytest.mark.parametrize( - ("yaml_config", "config_entry_data", "initial_response"), + ("config_entry_data", "initial_response"), [ - ({}, CONFIG_ENTRY_DATA, None), + (CONFIG_ENTRY_DATA, None), ], ids=["config_entry"], ) async def test_init_success( hass: HomeAssistant, - setup_integration: ComponentSetup, + config_entry: MockConfigEntry, responses: list[AiohttpClientMockResponse], initial_response: AiohttpClientMockResponse | None, ) -> None: @@ -38,49 +37,42 @@ async def test_init_success( if initial_response: responses.insert(0, initial_response) - assert await setup_integration() + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state == ConfigEntryState.LOADED - - await hass.config_entries.async_unload(entries[0].entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert entries[0].state is ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED @pytest.mark.parametrize( - ("yaml_config", "config_entry_data", "responses", "config_entry_states"), + ("config_entry_data", "responses", "config_entry_state"), [ ( - {}, CONFIG_ENTRY_DATA, [mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)], - [ConfigEntryState.SETUP_RETRY], + ConfigEntryState.SETUP_RETRY, ), ( - {}, CONFIG_ENTRY_DATA, [mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR)], - [ConfigEntryState.SETUP_RETRY], + ConfigEntryState.SETUP_RETRY, ), ( - {}, CONFIG_ENTRY_DATA, [ mock_response(MODEL_AND_VERSION_RESPONSE), mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), ], - [ConfigEntryState.SETUP_RETRY], + ConfigEntryState.SETUP_RETRY, ), ( - {}, CONFIG_ENTRY_DATA, [ mock_response(MODEL_AND_VERSION_RESPONSE), mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR), ], - [ConfigEntryState.SETUP_RETRY], + ConfigEntryState.SETUP_RETRY, ), ], ids=[ @@ -92,13 +84,10 @@ async def test_init_success( ) async def test_communication_failure( hass: HomeAssistant, - setup_integration: ComponentSetup, - config_entry_states: list[ConfigEntryState], + config_entry: MockConfigEntry, + config_entry_state: list[ConfigEntryState], ) -> None: """Test unable to talk to device on startup, which fails setup.""" - assert await setup_integration() - - assert [ - entry.state for entry in hass.config_entries.async_entries(DOMAIN) - ] == config_entry_states + await config_entry.async_setup(hass) + assert config_entry.state == config_entry_state diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index b3cfd56832d..0beae1f5a95 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components import number from homeassistant.components.rainbird import DOMAIN -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -17,11 +17,11 @@ from .conftest import ( RAIN_DELAY, RAIN_DELAY_OFF, SERIAL_NUMBER, - ComponentSetup, mock_response, mock_response_error, ) +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -31,20 +31,26 @@ def platforms() -> list[str]: return [Platform.NUMBER] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.mark.parametrize( ("rain_delay_response", "expected_state"), [(RAIN_DELAY, "16"), (RAIN_DELAY_OFF, "0")], ) async def test_number_values( hass: HomeAssistant, - setup_integration: ComponentSetup, expected_state: str, entity_registry: er.EntityRegistry, ) -> None: """Test number platform.""" - assert await setup_integration() - raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None assert raindelay.state == expected_state @@ -74,14 +80,11 @@ async def test_number_values( ) async def test_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, entity_unique_id: str, ) -> None: """Test number platform.""" - assert await setup_integration() - raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None assert ( @@ -95,15 +98,12 @@ async def test_unique_id( async def test_set_value( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[str], config_entry: ConfigEntry, ) -> None: """Test setting the rain delay number.""" - assert await setup_integration() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) assert device @@ -136,7 +136,6 @@ async def test_set_value( ) async def test_set_value_error( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[str], config_entry: ConfigEntry, @@ -145,8 +144,6 @@ async def test_set_value_error( ) -> None: """Test an error while talking to the device.""" - assert await setup_integration() - aioclient_mock.mock_calls.clear() responses.append(mock_response_error(status=status)) @@ -172,13 +169,10 @@ async def test_set_value_error( ) async def test_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, ) -> None: """Test number platform with no unique id.""" - assert await setup_integration() - raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None assert ( diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index d8fb053c0ff..00d778335c5 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -3,11 +3,14 @@ import pytest +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup +from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF + +from tests.common import MockConfigEntry @pytest.fixture @@ -16,20 +19,26 @@ def platforms() -> list[str]: return [Platform.SENSOR] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.mark.parametrize( ("rain_delay_response", "expected_state"), [(RAIN_DELAY, "16"), (RAIN_DELAY_OFF, "0")], ) async def test_sensors( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, expected_state: str, ) -> None: """Test sensor platform.""" - assert await setup_integration() - raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") assert raindelay is not None assert raindelay.state == expected_state @@ -66,14 +75,11 @@ async def test_sensors( ) async def test_sensor_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, config_entry_unique_id: str | None, ) -> None: """Test sensor platform with no unique id.""" - assert await setup_integration() - raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") assert raindelay is not None assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay" diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 31b64dded99..e2b6a99d01a 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -5,6 +5,7 @@ from http import HTTPStatus import pytest from homeassistant.components.rainbird import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -21,11 +22,11 @@ from .conftest import ( ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, - ComponentSetup, mock_response, mock_response_error, ) +from tests.common import MockConfigEntry from tests.components.switch import common as switch_common from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @@ -36,18 +37,24 @@ def platforms() -> list[str]: return [Platform.SWITCH] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.mark.parametrize( "stations_response", [EMPTY_STATIONS_RESPONSE], ) async def test_no_zones( hass: HomeAssistant, - setup_integration: ComponentSetup, ) -> None: """Test case where listing stations returns no stations.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_1") assert zone is None @@ -58,13 +65,10 @@ async def test_no_zones( ) async def test_zones( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_1") assert zone is not None assert zone.state == "off" @@ -110,14 +114,11 @@ async def test_zones( async def test_switch_on( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], ) -> None: """Test turning on irrigation switch.""" - assert await setup_integration() - # Initially all zones are off. Pick zone3 as an arbitrary to assert # state, then update below as a switch. zone = hass.states.get("switch.rain_bird_sprinkler_3") @@ -149,14 +150,11 @@ async def test_switch_on( ) async def test_switch_off( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], ) -> None: """Test turning off irrigation switch.""" - assert await setup_integration() - # Initially the test zone is on zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None @@ -182,15 +180,12 @@ async def test_switch_off( async def test_irrigation_service( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], api_responses: list[str], ) -> None: """Test calling the irrigation service.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "off" @@ -219,10 +214,9 @@ async def test_irrigation_service( @pytest.mark.parametrize( - ("yaml_config", "config_entry_data"), + ("config_entry_data"), [ ( - {}, { "host": HOST, "password": PASSWORD, @@ -232,17 +226,15 @@ async def test_irrigation_service( "1": "Garden Sprinkler", "2": "Back Yard", }, - }, + } ) ], ) async def test_yaml_imported_config( hass: HomeAssistant, - setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], ) -> None: """Test a config entry that was previously imported from yaml.""" - assert await setup_integration() assert hass.states.get("switch.garden_sprinkler") assert not hass.states.get("switch.rain_bird_sprinkler_1") @@ -260,7 +252,6 @@ async def test_yaml_imported_config( ) async def test_switch_error( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], status: HTTPStatus, @@ -268,8 +259,6 @@ async def test_switch_error( ) -> None: """Test an error talking to the device.""" - assert await setup_integration() - aioclient_mock.mock_calls.clear() responses.append(mock_response_error(status=status)) @@ -292,15 +281,12 @@ async def test_switch_error( ) async def test_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, ) -> None: """Test an irrigation switch with no unique id.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" @@ -321,7 +307,6 @@ async def test_no_unique_id( ) async def test_has_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, @@ -329,8 +314,6 @@ async def test_has_unique_id( ) -> None: """Test an irrigation switch with no unique id.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" From 859c5c48c4f55e5ba307362ce4c305ae5fac58bf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Nov 2023 23:49:31 +0100 Subject: [PATCH 309/982] Fix yaml loader tests to test both C and Python implementations (#103606) --- tests/util/yaml/test_init.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 315f6618e89..932bff01fd9 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -57,7 +57,7 @@ def test_simple_list(try_both_loaders) -> None: """Test simple list.""" conf = "config:\n - simple\n - list" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["config"] == ["simple", "list"] @@ -65,7 +65,7 @@ def test_simple_dict(try_both_loaders) -> None: """Test simple dict.""" conf = "key: value" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["key"] == "value" @@ -88,7 +88,7 @@ def test_environment_variable(try_both_loaders) -> None: os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["password"] == "secret_password" del os.environ["PASSWORD"] @@ -97,7 +97,7 @@ def test_environment_variable_default(try_both_loaders) -> None: """Test config file with default value for environment variable.""" conf = "password: !env_var PASSWORD secret_password" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["password"] == "secret_password" @@ -105,7 +105,7 @@ def test_invalid_environment_variable(try_both_loaders) -> None: """Test config file with no environment variable sat.""" conf = "password: !env_var PASSWORD" with pytest.raises(HomeAssistantError), io.StringIO(conf) as file: - yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + yaml_loader.parse_yaml(file) @pytest.mark.parametrize( @@ -118,7 +118,7 @@ def test_include_yaml( """Test include yaml.""" conf = "key: !include test.yaml" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["key"] == value @@ -134,7 +134,7 @@ def test_include_dir_list( conf = "key: !include_dir_list /test" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["key"] == sorted(["one", "two"]) @@ -162,7 +162,7 @@ def test_include_dir_list_recursive( conf = "key: !include_dir_list /test" with io.StringIO(conf) as file: assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert "tmp2" in mock_walk.return_value[0][1] assert ".ignore" not in mock_walk.return_value[0][1] assert sorted(doc["key"]) == sorted(["zero", "one", "two"]) @@ -184,7 +184,7 @@ def test_include_dir_named( conf = "key: !include_dir_named /test" correct = {"first": "one", "second": "two"} with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["key"] == correct @@ -213,7 +213,7 @@ def test_include_dir_named_recursive( correct = {"first": "one", "second": "two", "third": "three"} with io.StringIO(conf) as file: assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert "tmp2" in mock_walk.return_value[0][1] assert ".ignore" not in mock_walk.return_value[0][1] assert doc["key"] == correct @@ -232,7 +232,7 @@ def test_include_dir_merge_list( conf = "key: !include_dir_merge_list /test" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert sorted(doc["key"]) == sorted(["one", "two", "three"]) @@ -260,7 +260,7 @@ def test_include_dir_merge_list_recursive( conf = "key: !include_dir_merge_list /test" with io.StringIO(conf) as file: assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert "tmp2" in mock_walk.return_value[0][1] assert ".ignore" not in mock_walk.return_value[0][1] assert sorted(doc["key"]) == sorted(["one", "two", "three", "four"]) @@ -284,7 +284,7 @@ def test_include_dir_merge_named( conf = "key: !include_dir_merge_named /test" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["key"] == {"key1": "one", "key2": "two", "key3": "three"} @@ -312,7 +312,7 @@ def test_include_dir_merge_named_recursive( conf = "key: !include_dir_merge_named /test" with io.StringIO(conf) as file: assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert "tmp2" in mock_walk.return_value[0][1] assert ".ignore" not in mock_walk.return_value[0][1] assert doc["key"] == { From 947ce592c1e62113911a2828372202e359b1c87d Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Nov 2023 23:50:22 +0100 Subject: [PATCH 310/982] Remove obstruction detected property for covers in Overkiz (#103597) --- .../overkiz/cover_entities/generic_cover.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover_entities/generic_cover.py index 06f257d416b..b418bba9e41 100644 --- a/homeassistant/components/overkiz/cover_entities/generic_cover.py +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -1,7 +1,6 @@ """Base class for Overkiz covers, shutters, awnings, etc.""" from __future__ import annotations -from collections.abc import Mapping from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -115,17 +114,6 @@ class OverkizGenericCover(OverkizEntity, CoverEntity): for execution in self.coordinator.executions.values() ) - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the device state attributes.""" - attr = super().extra_state_attributes or {} - - # Obstruction Detected attribute is used by HomeKit - if self.executor.has_state(OverkizState.IO_PRIORITY_LOCK_LEVEL): - return {**attr, **{ATTR_OBSTRUCTION_DETECTED: True}} - - return attr - @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" From 2859055b36c05c93098e39ad47a511db4d2c0c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 7 Nov 2023 23:52:18 +0100 Subject: [PATCH 311/982] Add instance id to the cloud integration (#103162) --- homeassistant/components/cloud/client.py | 1 + homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/prefs.py | 15 +++++++++++++++ homeassistant/components/cloud/strings.json | 1 + homeassistant/components/cloud/system_health.py | 1 + tests/components/cloud/test_client.py | 8 ++++++-- tests/components/cloud/test_system_health.py | 2 ++ 7 files changed, 27 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 41ea4aa2b7d..019936869a1 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -230,6 +230,7 @@ class CloudClient(Interface): "alias": self.cloud.remote.alias, }, "version": HA_VERSION, + "instance_id": self.prefs.instance_id, } async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index bd9d61cde16..6e20978ec8d 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -13,6 +13,7 @@ PREF_GOOGLE_REPORT_STATE = "google_report_state" PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" PREF_ALEXA_REPORT_STATE = "alexa_report_state" PREF_DISABLE_2FA = "disable_2fa" +PREF_INSTANCE_ID = "instance_id" PREF_SHOULD_EXPOSE = "should_expose" PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" PREF_USERNAME = "username" diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 57179431574..4cc02867347 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from typing import Any +import uuid from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User @@ -33,6 +34,7 @@ from .const import ( PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SETTINGS_VERSION, + PREF_INSTANCE_ID, PREF_REMOTE_DOMAIN, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, @@ -91,6 +93,13 @@ class CloudPreferences: PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), } ) + if PREF_INSTANCE_ID not in self._prefs: + await self._save_prefs( + { + **self._prefs, + PREF_INSTANCE_ID: uuid.uuid4().hex, + } + ) @callback def async_listen_updates( @@ -264,6 +273,11 @@ class CloudPreferences: """Return the published cloud webhooks.""" return self._prefs.get(PREF_CLOUDHOOKS, {}) # type: ignore[no-any-return] + @property + def instance_id(self) -> str | None: + """Return the instance ID.""" + return self._prefs.get(PREF_INSTANCE_ID) + @property def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice.""" @@ -320,6 +334,7 @@ class CloudPreferences: PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), + PREF_INSTANCE_ID: uuid.uuid4().hex, PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, PREF_USERNAME: username, diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index aba2e770bc9..9c1f29cfcaf 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -13,6 +13,7 @@ "alexa_enabled": "Alexa Enabled", "google_enabled": "Google Enabled", "logged_in": "Logged In", + "instance_id": "Instance ID", "subscription_expiration": "Subscription Expiration" } }, diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 0dfd69344f3..d149e13c996 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -37,6 +37,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: data["google_enabled"] = client.prefs.google_enabled data["remote_server"] = cloud.remote.snitun_server data["certificate_status"] = cloud.remote.certificate_status + data["instance_id"] = client.prefs.instance_id data["can_reach_cert_server"] = system_health.async_check_can_reach_url( hass, f"https://{cloud.acme_server}/directory" diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index e205ba5f6e8..63ec6ad569d 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -1,6 +1,6 @@ """Test the cloud.iot module.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from aiohttp import web @@ -357,7 +357,10 @@ async def test_system_msg(hass: HomeAssistant) -> None: async def test_cloud_connection_info(hass: HomeAssistant) -> None: """Test connection info msg.""" - with patch("hass_nabucasa.Cloud.initialize"): + with patch("hass_nabucasa.Cloud.initialize"), patch( + "uuid.UUID.hex", new_callable=PropertyMock + ) as hexmock: + hexmock.return_value = "12345678901234567890" setup = await async_setup_component(hass, "cloud", {"cloud": {}}) assert setup cloud = hass.data["cloud"] @@ -372,4 +375,5 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: "alias": None, }, "version": HA_VERSION, + "instance_id": "12345678901234567890", } diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 79e45e9ba26..c540394b937 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -46,6 +46,7 @@ async def test_cloud_system_health( remote_enabled=True, alexa_enabled=True, google_enabled=False, + instance_id="12345678901234567890", ), ), ) @@ -70,4 +71,5 @@ async def test_cloud_system_health( "can_reach_cert_server": "ok", "can_reach_cloud_auth": {"type": "failed", "error": "unreachable"}, "can_reach_cloud": "ok", + "instance_id": "12345678901234567890", } From c69141236e8477ada21318d03a0ba5d40e0aaea5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 7 Nov 2023 23:54:06 +0100 Subject: [PATCH 312/982] Update radios to v0.2.0 (#103614) --- homeassistant/components/radio_browser/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/radio_browser/manifest.json b/homeassistant/components/radio_browser/manifest.json index 035c4bdda45..3aa94e0d402 100644 --- a/homeassistant/components/radio_browser/manifest.json +++ b/homeassistant/components/radio_browser/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/radio_browser", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["radios==0.1.1"] + "requirements": ["radios==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4ac5f5b1b0f..df9f24d3f31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2301,7 +2301,7 @@ qnapstats==0.4.0 quantum-gateway==0.0.8 # homeassistant.components.radio_browser -radios==0.1.1 +radios==0.2.0 # homeassistant.components.radiotherm radiotherm==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa92508a1e8..e799b7700d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1718,7 +1718,7 @@ qingping-ble==0.8.2 qnapstats==0.4.0 # homeassistant.components.radio_browser -radios==0.1.1 +radios==0.2.0 # homeassistant.components.radiotherm radiotherm==2.1.0 From d935d06265f41949008f3f4166ccb3f74fa36034 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Nov 2023 00:01:25 +0100 Subject: [PATCH 313/982] Remove myself from verisure codeowners (#103625) --- CODEOWNERS | 2 -- homeassistant/components/verisure/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 0381cb9aec6..3c48cf66311 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1384,8 +1384,6 @@ build.json @home-assistant/supervisor /homeassistant/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe -/homeassistant/components/verisure/ @frenck -/tests/components/verisure/ @frenck /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 70c0505929d..f6630f0c6e5 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck"], + "codeowners": [], "config_flow": true, "dhcp": [ { From a11091890f94111f8576c20397820c735cd042c4 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 8 Nov 2023 00:02:34 +0100 Subject: [PATCH 314/982] Support continue_on_error for command execution in Overkiz (#103591) --- homeassistant/components/overkiz/executor.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 9095ec8d38e..af29dbaf523 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -5,9 +5,12 @@ from typing import Any, cast from urllib.parse import urlparse from pyoverkiz.enums import OverkizCommand, Protocol +from pyoverkiz.exceptions import OverkizException from pyoverkiz.models import Command, Device, StateDefinition from pyoverkiz.types import StateType as OverkizStateType +from homeassistant.exceptions import HomeAssistantError + from .coordinator import OverkizDataUpdateCoordinator # Commands that don't support setting @@ -88,11 +91,15 @@ class OverkizExecutor: ): parameters.append(0) - exec_id = await self.coordinator.client.execute_command( - self.device.device_url, - Command(command_name, parameters), - "Home Assistant", - ) + try: + exec_id = await self.coordinator.client.execute_command( + self.device.device_url, + Command(command_name, parameters), + "Home Assistant", + ) + # Catch Overkiz exceptions to support `continue_on_error` functionality + except OverkizException as exception: + raise HomeAssistantError(exception) from exception # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here self.coordinator.executions[exec_id] = { From cbccdbc6fafbd12e29e1f2cff9c9041ed5591472 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 8 Nov 2023 00:03:47 +0100 Subject: [PATCH 315/982] Fix entity category for sensor fails mqtt sensor platform setup (#103449) --- .../components/mqtt/binary_sensor.py | 6 +- homeassistant/components/mqtt/mixins.py | 62 ++++++++++++-- homeassistant/components/mqtt/sensor.py | 4 +- homeassistant/components/mqtt/strings.json | 4 + tests/components/mqtt/test_init.py | 82 ++++++++++++++----- 5 files changed, 125 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index a89fb8a22fc..7608fc5816c 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -42,7 +42,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, - validate_sensor_entity_category, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -69,11 +68,12 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) DISCOVERY_SCHEMA = vol.All( - validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) -PLATFORM_SCHEMA_MODERN = vol.All(validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE) +PLATFORM_SCHEMA_MODERN = vol.All( + _PLATFORM_SCHEMA_BASE, +) async def async_setup_entry( diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 91a5511001b..d84f430bd85 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -29,7 +29,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, EntityCategory, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, async_get_hass, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -208,14 +208,60 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType ) -def validate_sensor_entity_category(config: ConfigType) -> ConfigType: +def validate_sensor_entity_category( + domain: str, discovery: bool +) -> Callable[[ConfigType], ConfigType]: """Check the sensor's entity category is not set to `config` which is invalid for sensors.""" - if ( - CONF_ENTITY_CATEGORY in config - and config[CONF_ENTITY_CATEGORY] == EntityCategory.CONFIG - ): - raise vol.Invalid("Entity category `config` is invalid") - return config + + # A guard was added to the core sensor platform with HA core 2023.11.0 + # See: https://github.com/home-assistant/core/pull/101471 + # A developers blog from october 2021 explains the correct uses of the entity category + # See: + # https://developers.home-assistant.io/blog/2021/10/26/config-entity/?_highlight=entity_category#entity-categories + # + # To limitate the impact of the change we use a grace period + # of 3 months for user to update there configs. + + def _validate(config: ConfigType) -> ConfigType: + if ( + CONF_ENTITY_CATEGORY in config + and config[CONF_ENTITY_CATEGORY] == EntityCategory.CONFIG + ): + config_str: str + if not discovery: + config_str = yaml_dump(config) + config.pop(CONF_ENTITY_CATEGORY) + _LOGGER.warning( + "Entity category `config` is invalid for sensors, ignoring. " + "This stops working from HA Core 2024.2.0" + ) + # We only open an issue if the user can fix it + if discovery: + return config + config_file = getattr(config, "__config_file__", "?") + line = getattr(config, "__line__", "?") + hass = async_get_hass() + async_create_issue( + hass, + domain=DOMAIN, + issue_id="invalid_entity_category", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="invalid_entity_category", + learn_more_url=( + f"https://www.home-assistant.io/integrations/{domain}.mqtt/" + ), + translation_placeholders={ + "domain": domain, + "config": config_str, + "config_file": config_file, + "line": line, + }, + ) + return config + + return _validate MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index e1c7ba64aba..2c173f801fa 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -88,7 +88,7 @@ PLATFORM_SCHEMA_MODERN = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), - validate_sensor_entity_category, + validate_sensor_entity_category(sensor.DOMAIN, discovery=False), _PLATFORM_SCHEMA_BASE, ) @@ -96,7 +96,7 @@ DISCOVERY_SCHEMA = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), - validate_sensor_entity_category, + validate_sensor_entity_category(sensor.DOMAIN, discovery=True), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index db0ed741ac0..7f8dcfedd9a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -20,6 +20,10 @@ "title": "MQTT entities with auxiliary heat support found", "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." }, + "invalid_entity_category": { + "title": "An MQTT {domain} with an invalid entity category was found", + "description": "Home Assistant detected a manually configured MQTT `{domain}` entity that has an `entity_category` set to `config`. \nConfiguration file: **{config_file}**\nNear line: **{line}**\n\nConfig with invalid setting:\n\n```yaml\n{config}\n```\n\nWhen set, make sure `entity_category` for a `{domain}` is set to `diagnostic` or `None`. Update your YAML configuration and restart Home Assistant to fix this issue." + }, "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 93d73094885..52c35d380f9 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -30,7 +30,12 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er, template +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, + template, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType @@ -42,6 +47,7 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, MockEntity, + async_capture_events, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -2152,26 +2158,20 @@ async def test_setup_manual_mqtt_with_invalid_config( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "entity_id"), [ - { - mqtt.DOMAIN: { - "sensor": { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", + ( + { + mqtt.DOMAIN: { + "sensor": { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } } - } - }, - { - mqtt.DOMAIN: { - "binary_sensor": { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - } - } - }, + }, + "sensor.test", + ), ], ) @patch( @@ -2181,10 +2181,52 @@ async def test_setup_manual_mqtt_with_invalid_entity_category( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + entity_id: str, ) -> None: """Test set up a manual sensor item with an invalid entity category.""" + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) assert await mqtt_mock_entry() - assert "Entity category `config` is invalid" in caplog.text + assert "Entity category `config` is invalid for sensors, ignoring" in caplog.text + state = hass.states.get(entity_id) + assert state is not None + assert len(events) == 1 + + +@pytest.mark.parametrize( + ("config", "entity_id"), + [ + ( + { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + }, + "sensor.test", + ), + ], +) +@patch( + "homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR, Platform.SENSOR] +) +async def test_setup_discovery_mqtt_with_invalid_entity_category( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + config: dict[str, Any], + entity_id: str, +) -> None: + """Test set up a discovered sensor item with an invalid entity category.""" + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + assert await mqtt_mock_entry() + + domain = entity_id.split(".")[0] + json_config = json.dumps(config) + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", json_config) + await hass.async_block_till_done() + assert "Entity category `config` is invalid for sensors, ignoring" in caplog.text + state = hass.states.get(entity_id) + assert state is not None + assert len(events) == 0 @patch("homeassistant.components.mqtt.PLATFORMS", []) From 36011d038431e74e87a330827fd308f33c1cb8a1 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 7 Nov 2023 18:04:23 -0500 Subject: [PATCH 316/982] Bump blinkpy to 0.22.3 (#103438) --- homeassistant/components/blink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 54f36ec6e2e..bb8fd4a5a51 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.2"] + "requirements": ["blinkpy==0.22.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index df9f24d3f31..2b8e083692e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -542,7 +542,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.2 +blinkpy==0.22.3 # homeassistant.components.bitcoin blockchain==1.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e799b7700d2..87c6bb93411 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -463,7 +463,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.2 +blinkpy==0.22.3 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 From 77a2f1664e0b8bb0dfcd71357dca0477217fff71 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 8 Nov 2023 01:05:17 +0200 Subject: [PATCH 317/982] Use EntityDescription for Transmission entities (#103581) --- .../components/transmission/const.py | 2 - .../components/transmission/sensor.py | 94 +++++++++---------- .../components/transmission/strings.json | 8 ++ .../components/transmission/switch.py | 42 ++++----- 4 files changed, 73 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 6074d03acf6..64b15c51691 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -7,8 +7,6 @@ from transmission_rpc import Torrent DOMAIN = "transmission" -SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle mode"} - ORDER_NEWEST_FIRST = "newest_first" ORDER_OLDEST_FIRST = "oldest_first" ORDER_BEST_RATIO_FIRST = "best_ratio_first" diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index d52a98a430e..20f4fc95c87 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -6,7 +6,11 @@ from typing import Any from transmission_rpc.torrent import Torrent -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant @@ -24,6 +28,25 @@ from .const import ( ) from .coordinator import TransmissionDataUpdateCoordinator +SPEED_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription(key="download", translation_key="download_speed"), + SensorEntityDescription(key="upload", translation_key="upload_speed"), +) + +STATUS_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription(key="status", translation_key="transmission_status"), +) + +TORRENT_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription(key="active_torrents", translation_key="active_torrents"), + SensorEntityDescription(key="paused_torrents", translation_key="paused_torrents"), + SensorEntityDescription(key="total_torrents", translation_key="total_torrents"), + SensorEntityDescription( + key="completed_torrents", translation_key="completed_torrents" + ), + SensorEntityDescription(key="started_torrents", translation_key="started_torrents"), +) + async def async_setup_entry( hass: HomeAssistant, @@ -36,47 +59,19 @@ async def async_setup_entry( config_entry.entry_id ] + entities: list[TransmissionSensor] = [] + entities = [ - TransmissionSpeedSensor( - coordinator, - "download_speed", - "download", - ), - TransmissionSpeedSensor( - coordinator, - "upload_speed", - "upload", - ), - TransmissionStatusSensor( - coordinator, - "transmission_status", - "status", - ), - TransmissionTorrentsSensor( - coordinator, - "active_torrents", - "active_torrents", - ), - TransmissionTorrentsSensor( - coordinator, - "paused_torrents", - "paused_torrents", - ), - TransmissionTorrentsSensor( - coordinator, - "total_torrents", - "total_torrents", - ), - TransmissionTorrentsSensor( - coordinator, - "completed_torrents", - "completed_torrents", - ), - TransmissionTorrentsSensor( - coordinator, - "started_torrents", - "started_torrents", - ), + TransmissionSpeedSensor(coordinator, description) + for description in SPEED_SENSORS + ] + entities += [ + TransmissionStatusSensor(coordinator, description) + for description in STATUS_SENSORS + ] + entities += [ + TransmissionTorrentsSensor(coordinator, description) + for description in TORRENT_SENSORS ] async_add_entities(entities) @@ -88,19 +83,18 @@ class TransmissionSensor( """A base class for all Transmission sensors.""" _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, coordinator: TransmissionDataUpdateCoordinator, - sensor_translation_key: str, - key: str, + entity_description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_translation_key = sensor_translation_key - self._key = key - self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}" + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{entity_description.key}" + ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, @@ -122,7 +116,7 @@ class TransmissionSpeedSensor(TransmissionSensor): data = self.coordinator.data return ( float(data.download_speed) - if self._key == "download" + if self.entity_description.key == "download" else float(data.upload_speed) ) @@ -173,7 +167,7 @@ class TransmissionTorrentsSensor(TransmissionSensor): torrents=self.coordinator.torrents, order=self.coordinator.order, limit=self.coordinator.limit, - statuses=self.MODES[self._key], + statuses=self.MODES[self.entity_description.key], ) return { STATE_ATTR_TORRENT_INFO: info, @@ -183,7 +177,7 @@ class TransmissionTorrentsSensor(TransmissionSensor): def native_value(self) -> int: """Return the count of the sensor.""" torrents = _filter_torrents( - self.coordinator.torrents, statuses=self.MODES[self._key] + self.coordinator.torrents, statuses=self.MODES[self.entity_description.key] ) return len(torrents) diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 77ffd6a8b2a..8a73eb90829 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -69,6 +69,14 @@ "started_torrents": { "name": "Started torrents" } + }, + "switch": { + "on_off": { + "name": "Switch" + }, + "turtle_mode": { + "name": "Turtle mode" + } } }, "services": { diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index 6d236964987..3d18fa3796c 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -1,20 +1,24 @@ """Support for setting the Transmission BitTorrent client Turtle Mode.""" -from collections.abc import Callable import logging from typing import Any -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SWITCH_TYPES +from .const import DOMAIN from .coordinator import TransmissionDataUpdateCoordinator _LOGGING = logging.getLogger(__name__) +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription(key="on_off", translation_key="on_off"), + SwitchEntityDescription(key="turtle_mode", translation_key="turtle_mode"), +) + async def async_setup_entry( hass: HomeAssistant, @@ -27,11 +31,9 @@ async def async_setup_entry( config_entry.entry_id ] - entities = [] - for switch_type, switch_name in SWITCH_TYPES.items(): - entities.append(TransmissionSwitch(switch_type, switch_name, coordinator)) - - async_add_entities(entities) + async_add_entities( + TransmissionSwitch(coordinator, description) for description in SWITCH_TYPES + ) class TransmissionSwitch( @@ -40,20 +42,18 @@ class TransmissionSwitch( """Representation of a Transmission switch.""" _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, - switch_type: str, - switch_name: str, coordinator: TransmissionDataUpdateCoordinator, + entity_description: SwitchEntityDescription, ) -> None: """Initialize the Transmission switch.""" super().__init__(coordinator) - self._attr_name = switch_name - self.type = switch_type - self.unsub_update: Callable[[], None] | None = None - self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{switch_type}" + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{entity_description.key}" + ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, @@ -64,19 +64,19 @@ class TransmissionSwitch( def is_on(self) -> bool: """Return true if device is on.""" active = None - if self.type == "on_off": + if self.entity_description.key == "on_off": active = self.coordinator.data.active_torrent_count > 0 - elif self.type == "turtle_mode": + elif self.entity_description.key == "turtle_mode": active = self.coordinator.get_alt_speed_enabled() return bool(active) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self.type == "on_off": + if self.entity_description.key == "on_off": _LOGGING.debug("Starting all torrents") await self.hass.async_add_executor_job(self.coordinator.start_torrents) - elif self.type == "turtle_mode": + elif self.entity_description.key == "turtle_mode": _LOGGING.debug("Turning Turtle Mode of Transmission on") await self.hass.async_add_executor_job( self.coordinator.set_alt_speed_enabled, True @@ -85,10 +85,10 @@ class TransmissionSwitch( async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self.type == "on_off": + if self.entity_description.key == "on_off": _LOGGING.debug("Stopping all torrents") await self.hass.async_add_executor_job(self.coordinator.stop_torrents) - if self.type == "turtle_mode": + if self.entity_description.key == "turtle_mode": _LOGGING.debug("Turning Turtle Mode of Transmission off") await self.hass.async_add_executor_job( self.coordinator.set_alt_speed_enabled, False From 41a235bb52c54738089d671dcdf39c3d9685fd8e Mon Sep 17 00:00:00 2001 From: Frederik Gladhorn Date: Wed, 8 Nov 2023 00:06:30 +0100 Subject: [PATCH 318/982] Improve HomeKit description of what the PIN looks like (#103170) Co-authored-by: J. Nick Koston --- homeassistant/components/homekit_controller/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index bc61b6fd42e..998c375aac1 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -12,7 +12,7 @@ }, "pair": { "title": "Pair with a device via HomeKit Accessory Protocol", - "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", + "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your eight digit HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging, often close to a HomeKit bar code, next to the image of a small house.", "data": { "pairing_code": "Pairing Code", "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." From e49f6b41eeb1009fe00376e1358c85739312a302 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Nov 2023 00:26:54 +0100 Subject: [PATCH 319/982] Rename YAML loader classes (#103609) --- homeassistant/util/yaml/loader.py | 95 ++++++++++++++----------------- 1 file changed, 43 insertions(+), 52 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 73e7861902f..8a8822ab17f 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -101,48 +101,11 @@ class Secrets: return secrets -class SafeLoader(FastestAvailableSafeLoader): - """The fastest available safe loader.""" +class _LoaderMixin: + """Mixin class with extensions for YAML loader.""" - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - self.stream = stream - if isinstance(stream, str): - self.name = "" - elif isinstance(stream, bytes): - self.name = "" - else: - self.name = getattr(stream, "name", "") - super().__init__(stream) - self.secrets = secrets - - def get_name(self) -> str: - """Get the name of the loader.""" - return self.name - - def get_stream_name(self) -> str: - """Get the name of the stream.""" - return self.stream.name or "" - - -class SafeLineLoader(yaml.SafeLoader): - """Loader class that keeps track of line numbers.""" - - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - super().__init__(stream) - self.secrets = secrets - - def compose_node( # type: ignore[override] - self, parent: yaml.nodes.Node, index: int - ) -> yaml.nodes.Node: - """Annotate a node with the first line it was seen.""" - last_line: int = self.line - node: yaml.nodes.Node = super().compose_node( # type: ignore[assignment] - parent, index - ) - node.__line__ = last_line + 1 # type: ignore[attr-defined] - return node + name: str + stream: Any def get_name(self) -> str: """Get the name of the loader.""" @@ -153,7 +116,35 @@ class SafeLineLoader(yaml.SafeLoader): return getattr(self.stream, "name", "") -LoaderType = SafeLineLoader | SafeLoader +class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): + """The fastest available safe loader, either C or Python.""" + + def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: + """Initialize a safe line loader.""" + self.stream = stream + + # Set name in same way as the Python loader does in yaml.reader.__init__ + if isinstance(stream, str): + self.name = "" + elif isinstance(stream, bytes): + self.name = "" + else: + self.name = getattr(stream, "name", "") + + super().__init__(stream) + self.secrets = secrets + + +class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): + """Python safe loader.""" + + def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: + """Initialize a safe line loader.""" + super().__init__(stream) + self.secrets = secrets + + +LoaderType = FastSafeLoader | PythonSafeLoader def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: @@ -171,31 +162,31 @@ def parse_yaml( ) -> JSON_TYPE: """Parse YAML with the fastest available loader.""" if not HAS_C_LOADER: - return _parse_yaml_pure_python(content, secrets) + return _parse_yaml_python(content, secrets) try: - return _parse_yaml(SafeLoader, content, secrets) + return _parse_yaml(FastSafeLoader, content, secrets) except yaml.YAMLError: - # Loading failed, so we now load with the slow line loader - # since the C one will not give us line numbers + # Loading failed, so we now load with the Python loader which has more + # readable exceptions if isinstance(content, (StringIO, TextIO, TextIOWrapper)): # Rewind the stream so we can try again content.seek(0, 0) - return _parse_yaml_pure_python(content, secrets) + return _parse_yaml_python(content, secrets) -def _parse_yaml_pure_python( +def _parse_yaml_python( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: - """Parse YAML with the pure python loader (this is very slow).""" + """Parse YAML with the python loader (this is very slow).""" try: - return _parse_yaml(SafeLineLoader, content, secrets) + return _parse_yaml(PythonSafeLoader, content, secrets) except yaml.YAMLError as exc: _LOGGER.error(str(exc)) raise HomeAssistantError(exc) from exc def _parse_yaml( - loader: type[SafeLoader] | type[SafeLineLoader], + loader: type[FastSafeLoader] | type[PythonSafeLoader], content: str | TextIO, secrets: Secrets | None = None, ) -> JSON_TYPE: @@ -404,7 +395,7 @@ def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: def add_constructor(tag: Any, constructor: Any) -> None: """Add to constructor to all loaders.""" - for yaml_loader in (SafeLoader, SafeLineLoader): + for yaml_loader in (FastSafeLoader, PythonSafeLoader): yaml_loader.add_constructor(tag, constructor) From 91ffe4f9e5cb7775235d0a74be0febde86c9715e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Nov 2023 00:55:52 +0100 Subject: [PATCH 320/982] Update sentry-sdk to 1.34.0 (#103623) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index fa1044414bb..3828a868649 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.31.0"] + "requirements": ["sentry-sdk==1.34.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2b8e083692e..b62383b96d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2413,7 +2413,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.31.0 +sentry-sdk==1.34.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87c6bb93411..5e9a82a00ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1794,7 +1794,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.31.0 +sentry-sdk==1.34.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 3e204ab82b14bba719e393ad3243942749657c7c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Nov 2023 01:09:44 +0100 Subject: [PATCH 321/982] Small cleanup in conftest mocks of PVOutput (#103628) --- tests/components/pvoutput/conftest.py | 13 ++---- tests/components/pvoutput/test_config_flow.py | 42 +++++++++---------- 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/tests/components/pvoutput/conftest.py b/tests/components/pvoutput/conftest.py index f99aee031e9..2bf85e5070e 100644 --- a/tests/components/pvoutput/conftest.py +++ b/tests/components/pvoutput/conftest.py @@ -34,21 +34,14 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup -@pytest.fixture -def mock_pvoutput_config_flow() -> Generator[None, MagicMock, None]: - """Return a mocked PVOutput client.""" - with patch( - "homeassistant.components.pvoutput.config_flow.PVOutput", autospec=True - ) as pvoutput_mock: - yield pvoutput_mock.return_value - - @pytest.fixture def mock_pvoutput() -> Generator[None, MagicMock, None]: """Return a mocked PVOutput client.""" with patch( "homeassistant.components.pvoutput.coordinator.PVOutput", autospec=True - ) as pvoutput_mock: + ) as pvoutput_mock, patch( + "homeassistant.components.pvoutput.config_flow.PVOutput", new=pvoutput_mock + ): pvoutput = pvoutput_mock.return_value pvoutput.status.return_value = Status.from_dict( load_json_object_fixture("status.json", DOMAIN) diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py index bf05afa020d..1839a7f51e0 100644 --- a/tests/components/pvoutput/test_config_flow.py +++ b/tests/components/pvoutput/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def test_full_user_flow( hass: HomeAssistant, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the full user configuration flow.""" @@ -42,12 +42,12 @@ async def test_full_user_flow( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 async def test_full_flow_with_authentication_error( hass: HomeAssistant, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the full user configuration flow with incorrect API key. @@ -62,7 +62,7 @@ async def test_full_flow_with_authentication_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" - mock_pvoutput_config_flow.system.side_effect = PVOutputAuthenticationError + mock_pvoutput.system.side_effect = PVOutputAuthenticationError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -76,9 +76,9 @@ async def test_full_flow_with_authentication_error( assert result2.get("errors") == {"base": "invalid_auth"} assert len(mock_setup_entry.mock_calls) == 0 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 - mock_pvoutput_config_flow.system.side_effect = None + mock_pvoutput.system.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={ @@ -95,14 +95,12 @@ async def test_full_flow_with_authentication_error( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 2 + assert len(mock_pvoutput.system.mock_calls) == 2 -async def test_connection_error( - hass: HomeAssistant, mock_pvoutput_config_flow: MagicMock -) -> None: +async def test_connection_error(hass: HomeAssistant, mock_pvoutput: MagicMock) -> None: """Test API connection error.""" - mock_pvoutput_config_flow.system.side_effect = PVOutputConnectionError + mock_pvoutput.system.side_effect = PVOutputConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -116,13 +114,13 @@ async def test_connection_error( assert result.get("type") == FlowResultType.FORM assert result.get("errors") == {"base": "cannot_connect"} - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 async def test_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, ) -> None: """Test we abort if the PVOutput system is already configured.""" mock_config_entry.add_to_hass(hass) @@ -146,7 +144,7 @@ async def test_already_configured( async def test_reauth_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the reauthentication configuration flow.""" @@ -178,13 +176,13 @@ async def test_reauth_flow( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 async def test_reauth_with_authentication_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the reauthentication configuration flow with an authentication error. @@ -206,7 +204,7 @@ async def test_reauth_with_authentication_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "reauth_confirm" - mock_pvoutput_config_flow.system.side_effect = PVOutputAuthenticationError + mock_pvoutput.system.side_effect = PVOutputAuthenticationError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "invalid_key"}, @@ -218,9 +216,9 @@ async def test_reauth_with_authentication_error( assert result2.get("errors") == {"base": "invalid_auth"} assert len(mock_setup_entry.mock_calls) == 0 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 - mock_pvoutput_config_flow.system.side_effect = None + mock_pvoutput.system.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={CONF_API_KEY: "valid_key"}, @@ -235,12 +233,12 @@ async def test_reauth_with_authentication_error( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 2 + assert len(mock_pvoutput.system.mock_calls) == 2 async def test_reauth_api_error( hass: HomeAssistant, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test API error during reauthentication.""" @@ -258,7 +256,7 @@ async def test_reauth_api_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "reauth_confirm" - mock_pvoutput_config_flow.system.side_effect = PVOutputConnectionError + mock_pvoutput.system.side_effect = PVOutputConnectionError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "some_new_key"}, From 780e6c06ecabd05bc6fd266b15cc44f4ee90a031 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Nov 2023 01:09:56 +0100 Subject: [PATCH 322/982] Update syrupy to 4.6.0 (#103626) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1dc9139fde7..eae3e8921cc 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -31,7 +31,7 @@ pytest-xdist==3.3.1 pytest==7.4.3 requests-mock==1.11.0 respx==0.20.2 -syrupy==4.5.0 +syrupy==4.6.0 tqdm==4.66.1 types-aiofiles==23.2.0.0 types-atomicwrites==1.4.5.1 From 2b12a95607afaca27fa3b254f3fd0a4001be7378 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Nov 2023 01:10:41 +0100 Subject: [PATCH 323/982] Update cryptography to 40.0.5 (#103624) --- 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 fac2abb7df1..b6f89c3af28 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.14.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.4 +cryptography==41.0.5 dbus-fast==2.12.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 diff --git a/pyproject.toml b/pyproject.toml index 5c3bee507ff..7e491044640 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "lru-dict==1.2.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.4", + "cryptography==41.0.5", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", "orjson==3.9.9", diff --git a/requirements.txt b/requirements.txt index 98d6e3864e2..51b55b121ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 PyJWT==2.8.0 -cryptography==41.0.4 +cryptography==41.0.5 pyOpenSSL==23.2.0 orjson==3.9.9 packaging>=23.1 From 22fa33ce7a98cd0c0a54fce3c678251b88e8a2c4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Nov 2023 01:11:07 +0100 Subject: [PATCH 324/982] Small cleanup in conftest mocks of Sensors.Community (#103630) --- tests/components/luftdaten/conftest.py | 16 ++++------------ tests/components/luftdaten/test_config_flow.py | 18 ++++++++---------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/tests/components/luftdaten/conftest.py b/tests/components/luftdaten/conftest.py index 248e1344f1b..08cbe7a2c3c 100644 --- a/tests/components/luftdaten/conftest.py +++ b/tests/components/luftdaten/conftest.py @@ -33,24 +33,16 @@ def mock_setup_entry() -> Generator[None, None, None]: yield -@pytest.fixture -def mock_luftdaten_config_flow() -> Generator[None, MagicMock, None]: - """Return a mocked Luftdaten client.""" - with patch( - "homeassistant.components.luftdaten.config_flow.Luftdaten", autospec=True - ) as luftdaten_mock: - luftdaten = luftdaten_mock.return_value - luftdaten.validate_sensor.return_value = True - yield luftdaten - - @pytest.fixture def mock_luftdaten() -> Generator[None, MagicMock, None]: """Return a mocked Luftdaten client.""" with patch( "homeassistant.components.luftdaten.Luftdaten", autospec=True - ) as luftdaten_mock: + ) as luftdaten_mock, patch( + "homeassistant.components.luftdaten.config_flow.Luftdaten", new=luftdaten_mock + ): luftdaten = luftdaten_mock.return_value + luftdaten.validate_sensor.return_value = True luftdaten.sensor_id = 12345 luftdaten.meta = { "altitude": 123.456, diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py index 5197a101bfd..a0b741f7d2a 100644 --- a/tests/components/luftdaten/test_config_flow.py +++ b/tests/components/luftdaten/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock from luftdaten.exceptions import LuftdatenConnectionError +import pytest from homeassistant.components.luftdaten import DOMAIN from homeassistant.components.luftdaten.const import CONF_SENSOR_ID @@ -36,7 +37,7 @@ async def test_duplicate_error( async def test_communication_error( - hass: HomeAssistant, mock_luftdaten_config_flow: MagicMock + hass: HomeAssistant, mock_luftdaten: MagicMock ) -> None: """Test that no sensor is added while unable to communicate with API.""" result = await hass.config_entries.flow.async_init( @@ -46,7 +47,7 @@ async def test_communication_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" - mock_luftdaten_config_flow.get_data.side_effect = LuftdatenConnectionError + mock_luftdaten.get_data.side_effect = LuftdatenConnectionError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_SENSOR_ID: 12345}, @@ -56,7 +57,7 @@ async def test_communication_error( assert result2.get("step_id") == "user" assert result2.get("errors") == {CONF_SENSOR_ID: "cannot_connect"} - mock_luftdaten_config_flow.get_data.side_effect = None + mock_luftdaten.get_data.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={CONF_SENSOR_ID: 12345}, @@ -70,9 +71,7 @@ async def test_communication_error( } -async def test_invalid_sensor( - hass: HomeAssistant, mock_luftdaten_config_flow: MagicMock -) -> None: +async def test_invalid_sensor(hass: HomeAssistant, mock_luftdaten: MagicMock) -> None: """Test that an invalid sensor throws an error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -81,7 +80,7 @@ async def test_invalid_sensor( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" - mock_luftdaten_config_flow.validate_sensor.return_value = False + mock_luftdaten.validate_sensor.return_value = False result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_SENSOR_ID: 11111}, @@ -91,7 +90,7 @@ async def test_invalid_sensor( assert result2.get("step_id") == "user" assert result2.get("errors") == {CONF_SENSOR_ID: "invalid_sensor"} - mock_luftdaten_config_flow.validate_sensor.return_value = True + mock_luftdaten.validate_sensor.return_value = True result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={CONF_SENSOR_ID: 12345}, @@ -105,10 +104,9 @@ async def test_invalid_sensor( } +@pytest.mark.usefixtures("mock_setup_entry", "mock_luftdaten") async def test_step_user( hass: HomeAssistant, - mock_setup_entry: MagicMock, - mock_luftdaten_config_flow: MagicMock, ) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( From a51bbe9a661e4adbf8007a0cc344eef1872666a7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Nov 2023 01:11:44 +0100 Subject: [PATCH 325/982] Add HDR switch to Reolink (#103550) --- homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/reolink/switch.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 15ba4baed45..0a496d62522 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -309,6 +309,9 @@ }, "doorbell_button_sound": { "name": "Doorbell button sound" + }, + "hdr": { + "name": "HDR" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 4bc817f9c52..f07db00e720 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -152,6 +152,16 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.doorbell_button_sound(ch), method=lambda api, ch, value: api.set_volume(ch, doorbell_button_sound=value), ), + ReolinkSwitchEntityDescription( + key="hdr", + translation_key="hdr", + icon="mdi:hdr", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + supported=lambda api, ch: api.supported(ch, "HDR"), + value=lambda api, ch: api.HDR_on(ch) is True, + method=lambda api, ch, value: api.set_HDR(ch, value), + ), ) NVR_SWITCH_ENTITIES = ( From 23578d8046d3e70d3ced6dd3082c4c6a757424d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Nov 2023 19:42:53 -0600 Subject: [PATCH 326/982] Bump dbus-fast to 2.13.1 (#103621) * Bump dbus-fast to 2.13.0 changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.12.0...v2.13.0 * no change re-release since upload failed due to running out of space on pypi --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 813bc900900..4ef14c60f40 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.14.0", - "dbus-fast==2.12.0" + "dbus-fast==2.13.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6f89c3af28..460a5340099 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ bluetooth-data-tools==1.14.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.5 -dbus-fast==2.12.0 +dbus-fast==2.13.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index b62383b96d3..d8b55f83c86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -657,7 +657,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.12.0 +dbus-fast==2.13.1 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e9a82a00ce..36ec6ff23a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -540,7 +540,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.12.0 +dbus-fast==2.13.1 # homeassistant.components.debugpy debugpy==1.8.0 From 0fdd929f54353f0b65c84db59c28823c9c38d8d5 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 8 Nov 2023 05:59:24 +0000 Subject: [PATCH 327/982] Add 4 new sensors to V2C (#103634) * add 4 sensors * no need for extra class --- homeassistant/components/v2c/sensor.py | 54 +++++++++++++++++------ homeassistant/components/v2c/strings.json | 12 +++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 60ef582ce8d..64aacf8e49e 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower +from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,21 +27,19 @@ _LOGGER = logging.getLogger(__name__) @dataclass -class V2CPowerRequiredKeysMixin: +class V2CRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[TrydanData], float] @dataclass -class V2CPowerSensorEntityDescription( - SensorEntityDescription, V2CPowerRequiredKeysMixin -): +class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): """Describes an EVSE Power sensor entity.""" -POWER_SENSORS = ( - V2CPowerSensorEntityDescription( +TRYDAN_SENSORS = ( + V2CSensorEntityDescription( key="charge_power", translation_key="charge_power", native_unit_of_measurement=UnitOfPower.WATT, @@ -49,6 +47,38 @@ POWER_SENSORS = ( device_class=SensorDeviceClass.POWER, value_fn=lambda evse_data: evse_data.charge_power, ), + V2CSensorEntityDescription( + key="charge_energy", + translation_key="charge_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda evse_data: evse_data.charge_energy, + ), + V2CSensorEntityDescription( + key="charge_time", + translation_key="charge_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda evse_data: evse_data.charge_time, + ), + V2CSensorEntityDescription( + key="house_power", + translation_key="house_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.house_power, + ), + V2CSensorEntityDescription( + key="fv_power", + translation_key="fv_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.fv_power, + ), ) @@ -61,8 +91,8 @@ async def async_setup_entry( coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[Entity] = [ - V2CPowerSensorEntity(coordinator, description, config_entry.entry_id) - for description in POWER_SENSORS + V2CSensorBaseEntity(coordinator, description, config_entry.entry_id) + for description in TRYDAN_SENSORS ] async_add_entities(entities) @@ -70,11 +100,7 @@ async def async_setup_entry( class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): """Defines a base v2c sensor entity.""" - -class V2CPowerSensorEntity(V2CSensorBaseEntity): - """V2C Power sensor entity.""" - - entity_description: V2CPowerSensorEntityDescription + entity_description: V2CSensorEntityDescription _attr_icon = "mdi:ev-station" def __init__( diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index 3a87f91ebc5..7ef658b5daa 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -19,6 +19,18 @@ "sensor": { "charge_power": { "name": "Charge power" + }, + "charge_energy": { + "name": "Charge energy" + }, + "charge_time": { + "name": "Charge time" + }, + "house_power": { + "name": "House power" + }, + "fv_power": { + "name": "Photovoltaic power" } } } From f69b8f37f824062015826d8795fd16c70693f0c3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Nov 2023 07:02:07 +0100 Subject: [PATCH 328/982] Update pytest-picked to 0.5.0 (#103631) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index eae3e8921cc..a13ab170086 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -26,7 +26,7 @@ pytest-test-groups==1.0.3 pytest-sugar==0.9.7 pytest-timeout==2.1.0 pytest-unordered==0.5.2 -pytest-picked==0.4.6 +pytest-picked==0.5.0 pytest-xdist==3.3.1 pytest==7.4.3 requests-mock==1.11.0 From a0f19f26c49044907e845ba18fbe59c47ebc267d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 8 Nov 2023 09:11:54 +0100 Subject: [PATCH 329/982] Bump awesomeversion from 23.8.0 to 23.11.0 (#103641) --- 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 460a5340099..37cb51d4178 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,7 +7,7 @@ astral==2.2 async-upnp-client==0.36.2 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 -awesomeversion==23.8.0 +awesomeversion==23.11.0 bcrypt==4.0.1 bleak-retry-connector==3.3.0 bleak==0.21.1 diff --git a/pyproject.toml b/pyproject.toml index 7e491044640..4b079aed093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", - "awesomeversion==23.8.0", + "awesomeversion==23.11.0", "bcrypt==4.0.1", "certifi>=2021.5.30", "ciso8601==2.3.0", diff --git a/requirements.txt b/requirements.txt index 51b55b121ea..324217b0f55 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohttp-zlib-ng==0.1.1 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 -awesomeversion==23.8.0 +awesomeversion==23.11.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 From 4f11ee6e0b9d7559944dca9591212c84328b50d7 Mon Sep 17 00:00:00 2001 From: dupondje Date: Wed, 8 Nov 2023 09:13:51 +0100 Subject: [PATCH 330/982] Fix 5B Gas meter in dsmr (#103506) * Fix 5B Gas meter in dsmr In commit 1b73219 the gas meter broke for 5B. As the change can't be reverted easily without removing the peak usage sensors, we implement a workaround. The first MBUS_METER_READING2 value will contain the gas meter data just like the previous BELGIUM_5MIN_GAS_METER_READING did. But this without the need to touch dsmr_parser (version). Fixes: #103306, #103293 * Use parametrize * Apply suggestions from code review Co-authored-by: Jan Bouwhuis * Add additional tests + typo fix --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/dsmr/const.py | 3 - homeassistant/components/dsmr/sensor.py | 42 +++++-- tests/components/dsmr/test_sensor.py | 152 +++++++++++++++++++++++- 3 files changed, 179 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 7bc0247aea6..5e1a54aedc4 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -34,6 +34,3 @@ DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} DSMR_PROTOCOL = "dsmr_protocol" RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol" - -# Temp obis until sensors replaced by mbus variants -BELGIUM_5MIN_GAS_METER_READING = r"\d-\d:24\.2\.3.+?\r\n" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 99af30b8111..fa58bd8c5a6 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -44,7 +44,6 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle from .const import ( - BELGIUM_5MIN_GAS_METER_READING, CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, @@ -382,16 +381,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, ), - DSMRSensorEntityDescription( - key="belgium_5min_gas_meter_reading", - translation_key="gas_meter_reading", - obis_reference=BELGIUM_5MIN_GAS_METER_READING, - dsmr_versions={"5B"}, - is_gas=True, - force_update=True, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL_INCREASING, - ), DSMRSensorEntityDescription( key="gas_meter_reading", translation_key="gas_meter_reading", @@ -405,6 +394,31 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ) +def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription: + """Return correct entity for 5B Gas meter.""" + ref = None + if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram: + ref = obis_references.BELGIUM_MBUS1_METER_READING2 + elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram: + ref = obis_references.BELGIUM_MBUS2_METER_READING2 + elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram: + ref = obis_references.BELGIUM_MBUS3_METER_READING2 + elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram: + ref = obis_references.BELGIUM_MBUS4_METER_READING2 + elif ref is None: + ref = obis_references.BELGIUM_MBUS1_METER_READING2 + return DSMRSensorEntityDescription( + key="belgium_5min_gas_meter_reading", + translation_key="gas_meter_reading", + obis_reference=ref, + dsmr_versions={"5B"}, + is_gas=True, + force_update=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -438,6 +452,10 @@ async def async_setup_entry( return (entity_description.device_class, UNIT_CONVERSION[uom]) return (entity_description.device_class, uom) + all_sensors = SENSORS + if dsmr_version == "5B": + all_sensors += (add_gas_sensor_5B(telegram),) + entities.extend( [ DSMREntity( @@ -448,7 +466,7 @@ async def async_setup_entry( telegram, description ), # type: ignore[arg-type] ) - for description in SENSORS + for description in all_sensors if ( description.dsmr_versions is None or dsmr_version in description.dsmr_versions diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 9c8c4e6fc70..e7f0e715f59 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -8,10 +8,22 @@ import asyncio import datetime from decimal import Decimal from itertools import chain, repeat +from typing import Literal from unittest.mock import DEFAULT, MagicMock +from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS1_METER_READING2, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_METER_READING1, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_METER_READING1, + BELGIUM_MBUS4_METER_READING2, +) +import pytest + from homeassistant import config_entries -from homeassistant.components.dsmr.const import BELGIUM_5MIN_GAS_METER_READING from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -483,6 +495,10 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No from dsmr_parser.obis_references import ( BELGIUM_CURRENT_AVERAGE_DEMAND, BELGIUM_MAXIMUM_DEMAND_MONTH, + BELGIUM_MBUS1_METER_READING2, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_METER_READING2, ELECTRICITY_ACTIVE_TARIFF, ) from dsmr_parser.objects import CosemObject, MBusObject @@ -500,13 +516,34 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No } telegram = { - BELGIUM_5MIN_GAS_METER_READING: MBusObject( - BELGIUM_5MIN_GAS_METER_READING, + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, ], ), + BELGIUM_MBUS2_METER_READING2: MBusObject( + BELGIUM_MBUS2_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(745.696), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"value": Decimal(745.697), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_METER_READING2: MBusObject( + BELGIUM_MBUS4_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(745.698), "unit": "m3"}, + ], + ), BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( BELGIUM_CURRENT_AVERAGE_DEMAND, [{"value": Decimal(1.75), "unit": "kW"}], @@ -577,6 +614,115 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No ) +@pytest.mark.parametrize( + ("key1", "key2", "key3", "gas_value"), + [ + ( + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_METER_READING1, + "745.696", + ), + ( + BELGIUM_MBUS1_METER_READING2, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_METER_READING2, + "745.695", + ), + ( + BELGIUM_MBUS4_METER_READING2, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_METER_READING1, + "745.695", + ), + ( + BELGIUM_MBUS4_METER_READING1, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_METER_READING2, + "745.697", + ), + ], +) +async def test_belgian_meter_alt( + hass: HomeAssistant, + dsmr_connection_fixture, + key1: Literal, + key2: Literal, + key3: Literal, + gas_value: str, +) -> None: + """Test if Belgian meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.objects import MBusObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "reconnect_interval": 30, + "serial_id": "1234", + "serial_id_gas": "5678", + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + key1: MBusObject( + key1, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + key2: MBusObject( + key2, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(745.696), "unit": "m3"}, + ], + ), + key3: MBusObject( + key3, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"value": Decimal(745.697), "unit": "m3"}, + ], + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + # check if gas consumption is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") + assert gas_consumption.state == gas_value + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture From 8e9528d34d7ab3403a3853b9a0001ab772843915 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 8 Nov 2023 03:28:38 -0500 Subject: [PATCH 331/982] Bump pydrawise to 2023.11.0 (#103638) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 4e73a2ba64c..054d084eb76 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2023.10.0"] + "requirements": ["pydrawise==2023.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8b55f83c86..9076c2699ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1678,7 +1678,7 @@ pydiscovergy==2.0.5 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2023.10.0 +pydrawise==2023.11.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36ec6ff23a4..6338d079447 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1266,7 +1266,7 @@ pydexcom==0.2.3 pydiscovergy==2.0.5 # homeassistant.components.hydrawise -pydrawise==2023.10.0 +pydrawise==2023.11.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 40dc6d8191a001c973bf25d22972fcec1681a7a5 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 8 Nov 2023 09:55:00 +0100 Subject: [PATCH 332/982] Reduce modbus validator by using table (#103488) --- homeassistant/components/modbus/validators.py | 31 ++++++++----------- tests/components/modbus/test_sensor.py | 2 +- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 5fa314d589c..7c007ee0279 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -98,27 +98,22 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: data_type = config[CONF_DATA_TYPE] = DataType.INT16 count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) - slave_count = config.get(CONF_SLAVE_COUNT, None) - slave_name = CONF_SLAVE_COUNT - if not slave_count: - slave_count = config.get(CONF_VIRTUAL_COUNT, 0) - slave_name = CONF_VIRTUAL_COUNT + slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT, 0)) swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm - if count and not validator.count: - error = f"{name}: `{CONF_COUNT}: {count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) - if not count and validator.count: - error = f"{name}: `{CONF_COUNT}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) - if structure and not validator.structure: - error = f"{name}: `{CONF_STRUCTURE}: {structure}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) - if not structure and validator.structure: - error = f"{name}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) + for entry in ( + (count, validator.count, CONF_COUNT), + (structure, validator.structure, CONF_STRUCTURE), + ): + if bool(entry[0]) != entry[1]: + error = "cannot be combined" if not entry[1] else "missing, demanded" + error = ( + f"{name}: `{entry[2]}:` {error} with `{CONF_DATA_TYPE}: {data_type}`" + ) + raise vol.Invalid(error) + if slave_count and not validator.slave_count: - error = f"{name}: `{slave_name}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + error = f"{name}: `{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}:` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) if swap_type != CONF_SWAP_NONE: swap_type_validator = { diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 72aebbd396f..1c627faa09c 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -247,7 +247,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", + f"{TEST_ENTITY_NAME}: `{CONF_STRUCTURE}:` missing, demanded with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ( { From cc5eda76d3c092062e5b1c33663eced2771e0d33 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Nov 2023 10:15:27 +0100 Subject: [PATCH 333/982] Humanize core config errors in check_config helper (#103635) --- homeassistant/helpers/check_config.py | 4 +++- tests/helpers/test_check_config.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 64e57b09d59..f65cd4e359e 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -151,7 +151,9 @@ async def async_check_ha_config_file( # noqa: C901 core_config = CORE_CONFIG_SCHEMA(core_config) result[CONF_CORE] = core_config except vol.Invalid as err: - result.add_error(err, CONF_CORE, core_config) + result.add_error( + _format_config_error(err, CONF_CORE, core_config)[0], CONF_CORE, core_config + ) core_config = {} # Merge packages diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 73d6433315e..197fb88695f 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -73,7 +73,11 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: log_ha_config(res) error = CheckConfigError( - "not a valid value for dictionary value @ data['unit_system']", + ( + "Invalid config for [homeassistant]: not a valid value for dictionary " + "value @ data['unit_system']. Got 'bad'. (See " + f"{hass.config.path(YAML_CONFIG_FILE)}, line 2). " + ), "homeassistant", {"unit_system": "bad"}, ) From 81cb7470fce787ba5e5307472ca8e201a7f7433c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 8 Nov 2023 11:06:14 +0100 Subject: [PATCH 334/982] Remove illegal int8 from modbus config (#103489) --- homeassistant/components/modbus/__init__.py | 2 -- homeassistant/components/modbus/const.py | 2 -- homeassistant/components/modbus/validators.py | 2 -- 3 files changed, 6 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index a2b0c24464c..14f8b59ddee 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -162,11 +162,9 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_COUNT): cv.positive_int, vol.Optional(CONF_DATA_TYPE, default=DataType.INT16): vol.In( [ - DataType.INT8, DataType.INT16, DataType.INT32, DataType.INT64, - DataType.UINT8, DataType.UINT16, DataType.UINT32, DataType.UINT64, diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 92a38bb5e92..a52f8ccfc97 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -85,11 +85,9 @@ class DataType(str, Enum): CUSTOM = "custom" STRING = "string" - INT8 = "int8" INT16 = "int16" INT32 = "int32" INT64 = "int64" - UINT8 = "uint8" UINT16 = "uint16" UINT32 = "uint32" UINT64 = "uint64" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 7c007ee0279..fbf56d97b51 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -73,8 +73,6 @@ PARM_IS_LEGAL = namedtuple( # As expressed in DEFAULT_STRUCT_FORMAT DEFAULT_STRUCT_FORMAT = { - DataType.INT8: ENTRY("b", 1, PARM_IS_LEGAL(False, False, False, False, False)), - DataType.UINT8: ENTRY("c", 1, PARM_IS_LEGAL(False, False, False, False, False)), DataType.INT16: ENTRY("h", 1, PARM_IS_LEGAL(False, False, True, True, False)), DataType.UINT16: ENTRY("H", 1, PARM_IS_LEGAL(False, False, True, True, False)), DataType.FLOAT16: ENTRY("e", 1, PARM_IS_LEGAL(False, False, True, True, False)), From 381d8abd589edd4ac3aedc4d8ebd6683b648117f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Nov 2023 11:29:50 +0100 Subject: [PATCH 335/982] Bump sigstore/cosign-installer from 3.1.2 to 3.2.0 (#103640) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index c73a7bac340..9d13c07301e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -330,7 +330,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Install Cosign - uses: sigstore/cosign-installer@v3.1.2 + uses: sigstore/cosign-installer@v3.2.0 with: cosign-release: "v2.0.2" From 8e997605950a0b886cf444d8cc6ebbc03c84bfa4 Mon Sep 17 00:00:00 2001 From: Ville Hartikainen Date: Wed, 8 Nov 2023 12:33:19 +0200 Subject: [PATCH 336/982] Add vscode task to run changed tests (#103501) --- .vscode/launch.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index c165e252b1a..78e0dda152b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,6 +22,14 @@ "args": ["--debug", "-c", "config", "--skip-pip"], "preLaunchTask": "Compile English translations" }, + { + "name": "Home Assistant: Changed tests", + "type": "python", + "request": "launch", + "module": "pytest", + "justMyCode": false, + "args": ["--timeout=10", "--picked"], + }, { // Debug by attaching to local Home Assistant server using Remote Python Debugger. // See https://www.home-assistant.io/integrations/debugpy/ From 3697567f18e7b857d439e43c6ec1f82c00f078c6 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 8 Nov 2023 12:32:37 +0100 Subject: [PATCH 337/982] Remove redundant exception and catch NotSuchTokenException in Overkiz integration (#103584) --- homeassistant/components/overkiz/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 95fc2af8e06..d3fdda07f74 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -5,13 +5,14 @@ import asyncio from collections import defaultdict from dataclasses import dataclass -from aiohttp import ClientError, ServerDisconnectedError +from aiohttp import ClientError from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS from pyoverkiz.enums import OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotSuchTokenException, TooManyRequestsException, ) from pyoverkiz.models import Device, Scenario @@ -67,11 +68,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.get_scenarios(), ] ) - except BadCredentialsException as exception: + except (BadCredentialsException, NotSuchTokenException) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: raise ConfigEntryNotReady("Too many requests, try again later") from exception - except (TimeoutError, ClientError, ServerDisconnectedError) as exception: + except (TimeoutError, ClientError) as exception: raise ConfigEntryNotReady("Failed to connect") from exception except MaintenanceException as exception: raise ConfigEntryNotReady("Server is down for maintenance") from exception From 0a2a699133728e3334a0b513a849127f36531fe3 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 8 Nov 2023 12:40:28 +0100 Subject: [PATCH 338/982] Extend climate tests for nibe heatpump (#103522) --- .coveragerc | 1 - .../components/nibe_heatpump/climate.py | 13 +- .../nibe_heatpump/snapshots/test_climate.ambr | 468 +++++++++++++++++- .../components/nibe_heatpump/test_climate.py | 293 ++++++++++- 4 files changed, 745 insertions(+), 30 deletions(-) diff --git a/.coveragerc b/.coveragerc index 0bd6d40ac34..d58eafa442c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -819,7 +819,6 @@ omit = homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py - homeassistant/components/nibe_heatpump/climate.py homeassistant/components/nibe_heatpump/binary_sensor.py homeassistant/components/nibe_heatpump/select.py homeassistant/components/nibe_heatpump/sensor.py diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 99109ed8609..6280994bd7d 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -24,7 +24,6 @@ from homeassistant.components.climate import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -48,10 +47,7 @@ async def async_setup_entry( coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] - main_unit = UNIT_COILGROUPS.get(coordinator.series, {}).get("main") - if not main_unit: - LOGGER.debug("Skipping climates - no main unit found") - return + main_unit = UNIT_COILGROUPS[coordinator.series]["main"] def climate_systems(): for key, group in CLIMATE_COILGROUPS.get(coordinator.series, ()).items(): @@ -128,9 +124,6 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): @callback def _handle_coordinator_update(self) -> None: - if not self.coordinator.data: - return - def _get_value(coil: Coil) -> int | str | float | None: return self.coordinator.get_coil_value(coil) @@ -179,7 +172,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): else: self._attr_hvac_action = HVACAction.IDLE else: - self._attr_hvac_action = None + self._attr_hvac_action = HVACAction.OFF self.async_write_ha_state() @@ -247,4 +240,4 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): ) await coordinator.async_write_coil(self._coil_use_room_sensor, "OFF") else: - raise HomeAssistantError(f"{hvac_mode} mode not supported for {self.name}") + raise ValueError(f"{hvac_mode} mode not supported for {self.name}") diff --git a/tests/components/nibe_heatpump/snapshots/test_climate.ambr b/tests/components/nibe_heatpump/snapshots/test_climate.ambr index 3d08565e105..f19fd69c47d 100644 --- a/tests/components/nibe_heatpump/snapshots/test_climate.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_climate.ambr @@ -1,5 +1,391 @@ # serializer version: 1 -# name: test_basic[Model.S320-s1-climate.climate_system_s1][1. initial] +# name: test_active_accessory[Model.F1155-s2-climate.climate_system_s2][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_active_accessory[Model.F1155-s2-climate.climate_system_s2][unavailable (not supported)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Climate System S2', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 0.5, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_active_accessory[Model.F1155-s3-climate.climate_system_s3][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S3', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s3', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_active_accessory[Model.F1155-s3-climate.climate_system_s3][unavailable (not supported)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Climate System S3', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 0.5, + }), + 'context': , + 'entity_id': 'climate.climate_system_s3', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_active_accessory[Model.S320-s2-climate.climate_system_21][initial] + None +# --- +# name: test_active_accessory[Model.S320-s2-climate.climate_system_s1][initial] + None +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][cooling] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][heating (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][heating (only)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][heating] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][idle (mixing valve)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][off (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][unavailable] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][cooling] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][heating (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][heating (only)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][heating] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 20.5, @@ -25,7 +411,7 @@ 'state': 'heat_cool', }) # --- -# name: test_basic[Model.S320-s1-climate.climate_system_s1][2. idle] +# name: test_basic[Model.S320-s1-climate.climate_system_s1][idle (mixing valve)] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 20.5, @@ -51,3 +437,81 @@ 'state': 'heat_cool', }) # --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][off (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][unavailable] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index d4084ce8123..2b3fe5d8c0e 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -1,13 +1,29 @@ """Test the Nibe Heat Pump config flow.""" from typing import Any -from unittest.mock import patch +from unittest.mock import call, patch -from nibe.coil_groups import CLIMATE_COILGROUPS, UNIT_COILGROUPS +from nibe.coil import CoilData +from nibe.coil_groups import ( + CLIMATE_COILGROUPS, + UNIT_COILGROUPS, + ClimateCoilGroup, + UnitCoilGroup, +) from nibe.heatpump import Model import pytest from syrupy import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from . import MockConnection, async_add_model @@ -20,10 +36,31 @@ async def fixture_single_platform(): yield +def _setup_climate_group( + coils: dict[int, Any], model: Model, climate_id: str +) -> tuple[ClimateCoilGroup, UnitCoilGroup]: + """Initialize coils for a climate group, with some default values.""" + climate = CLIMATE_COILGROUPS[model.series][climate_id] + unit = UNIT_COILGROUPS[model.series]["main"] + + if climate.active_accessory is not None: + coils[climate.active_accessory] = "ON" + coils[climate.current] = 20.5 + coils[climate.setpoint_heat] = 21.0 + coils[climate.setpoint_cool] = 30.0 + coils[climate.mixing_valve_state] = 20 + coils[climate.use_room_sensor] = "ON" + coils[unit.prio] = "OFF" + coils[unit.cooling_with_room_sensor] = "ON" + + return climate, unit + + @pytest.mark.parametrize( ("model", "climate_id", "entity_id"), [ (Model.S320, "s1", "climate.climate_system_s1"), + (Model.F1155, "s2", "climate.climate_system_s2"), ], ) async def test_basic( @@ -37,22 +74,244 @@ async def test_basic( snapshot: SnapshotAssertion, ) -> None: """Test setting of value.""" - climate = CLIMATE_COILGROUPS[model.series][climate_id] - unit = UNIT_COILGROUPS[model.series]["main"] - if climate.active_accessory is not None: - coils[climate.active_accessory] = "ON" - coils[climate.current] = 20.5 - coils[climate.setpoint_heat] = 21.0 - coils[climate.setpoint_cool] = 30.0 - coils[climate.mixing_valve_state] = "ON" - coils[climate.use_room_sensor] = "ON" - coils[unit.prio] = "HEAT" - coils[unit.cooling_with_room_sensor] = "ON" + climate, unit = _setup_climate_group(coils, model, climate_id) await async_add_model(hass, model) - assert hass.states.get(entity_id) == snapshot(name="1. initial") + assert hass.states.get(entity_id) == snapshot(name="initial") - mock_connection.mock_coil_update(unit.prio, "OFF") + mock_connection.mock_coil_update(unit.prio, "COOLING") + assert hass.states.get(entity_id) == snapshot(name="cooling") - assert hass.states.get(entity_id) == snapshot(name="2. idle") + mock_connection.mock_coil_update(unit.prio, "HEAT") + assert hass.states.get(entity_id) == snapshot(name="heating") + + mock_connection.mock_coil_update(climate.mixing_valve_state, 30) + assert hass.states.get(entity_id) == snapshot(name="idle (mixing valve)") + + mock_connection.mock_coil_update(climate.mixing_valve_state, 20) + mock_connection.mock_coil_update(unit.cooling_with_room_sensor, "OFF") + assert hass.states.get(entity_id) == snapshot(name="heating (only)") + + mock_connection.mock_coil_update(climate.use_room_sensor, "OFF") + assert hass.states.get(entity_id) == snapshot(name="heating (auto)") + + mock_connection.mock_coil_update(unit.prio, None) + assert hass.states.get(entity_id) == snapshot(name="off (auto)") + + coils.clear() + assert hass.states.get(entity_id) == snapshot(name="unavailable") + + +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.F1155, "s2", "climate.climate_system_s2"), + (Model.F1155, "s3", "climate.climate_system_s3"), + ], +) +async def test_active_accessory( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test climate groups that can be deactivated by configuration.""" + climate, unit = _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + assert hass.states.get(entity_id) == snapshot(name="initial") + + mock_connection.mock_coil_update(climate.active_accessory, "OFF") + assert hass.states.get(entity_id) == snapshot(name="unavailable (not supported)") + + +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.S320, "s1", "climate.climate_system_s1"), + (Model.F1155, "s2", "climate.climate_system_s2"), + ], +) +async def test_set_temperature( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test setting temperature.""" + climate, _ = _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + coil_setpoint_heat = mock_connection.heatpump.get_coil_by_address( + climate.setpoint_heat + ) + coil_setpoint_cool = mock_connection.heatpump.get_coil_by_address( + climate.setpoint_cool + ) + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_setpoint_heat, 22)) + ] + mock_connection.write_coil.reset_mock() + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22, + ATTR_HVAC_MODE: HVACMode.COOL, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_setpoint_cool, 22)) + ] + mock_connection.write_coil.reset_mock() + + with pytest.raises(ValueError): + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22, + }, + blocking=True, + ) + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 22, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_setpoint_heat, 22)), + call(CoilData(coil_setpoint_cool, 30)), + ] + + mock_connection.write_coil.reset_mock() + + +@pytest.mark.parametrize( + ("hvac_mode", "cooling_with_room_sensor", "use_room_sensor"), + [ + (HVACMode.HEAT_COOL, "ON", "ON"), + (HVACMode.HEAT, "OFF", "ON"), + (HVACMode.AUTO, "OFF", "OFF"), + ], +) +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.S320, "s1", "climate.climate_system_s1"), + (Model.F1155, "s2", "climate.climate_system_s2"), + ], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + cooling_with_room_sensor: str, + use_room_sensor: str, + hvac_mode: HVACMode, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, +) -> None: + """Test setting a hvac mode.""" + climate, unit = _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + coil_use_room_sensor = mock_connection.heatpump.get_coil_by_address( + climate.use_room_sensor + ) + coil_cooling_with_room_sensor = mock_connection.heatpump.get_coil_by_address( + unit.cooling_with_room_sensor + ) + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: hvac_mode, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_cooling_with_room_sensor, cooling_with_room_sensor)), + call(CoilData(coil_use_room_sensor, use_room_sensor)), + ] + + +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.S320, "s1", "climate.climate_system_s1"), + (Model.F1155, "s2", "climate.climate_system_s2"), + ], +) +async def test_set_invalid_hvac_mode( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, +) -> None: + """Test setting an invalid hvac mode.""" + _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + with pytest.raises(ValueError): + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.DRY, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [] From a78ef6077350a5dd5d9e1a342f0f7d9a71e7c49a Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Wed, 8 Nov 2023 12:46:15 +0100 Subject: [PATCH 339/982] Add duotecno OFF hvac mode (#103223) --- homeassistant/components/duotecno/climate.py | 12 ++++++------ homeassistant/components/duotecno/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index e7dfa53e53c..8e23e742c04 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -23,12 +23,7 @@ HVACMODE: Final = { } HVACMODE_REVERSE: Final = {value: key for key, value in HVACMODE.items()} -PRESETMODES: Final = { - "sun": 0, - "half_sun": 1, - "moon": 2, - "half_moon": 3, -} +PRESETMODES: Final = {"sun": 0, "half_sun": 1, "moon": 2, "half_moon": 3} PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()} @@ -88,5 +83,10 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): """Set the preset mode.""" await self._unit.set_preset(PRESETMODES[preset_mode]) + @api_call async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Duotecno does not support setting this, we can only display it.""" + if hvac_mode == HVACMode.OFF: + await self._unit.turn_off() + else: + await self._unit.turn_on() diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 60f59e865df..2f221929178 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2023.10.1"] + "requirements": ["pyDuotecno==2023.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9076c2699ff..b0da7f8d209 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1557,7 +1557,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.10.1 +pyDuotecno==2023.11.1 # homeassistant.components.electrasmart pyElectra==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6338d079447..906919a3129 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1190,7 +1190,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.10.1 +pyDuotecno==2023.11.1 # homeassistant.components.electrasmart pyElectra==1.2.0 From 44fe704f49d1afb3da417d16b9b53de6be1a4930 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 8 Nov 2023 06:48:05 -0500 Subject: [PATCH 340/982] Bump python-roborock to 0.36.0 (#103465) --- .../components/roborock/binary_sensor.py | 2 +- .../components/roborock/coordinator.py | 18 ++++++----- homeassistant/components/roborock/device.py | 12 +++---- .../components/roborock/manifest.json | 2 +- homeassistant/components/roborock/number.py | 2 +- homeassistant/components/roborock/select.py | 31 +++++++++++-------- homeassistant/components/roborock/sensor.py | 12 ++++--- homeassistant/components/roborock/switch.py | 2 +- homeassistant/components/roborock/time.py | 2 +- homeassistant/components/roborock/vacuum.py | 7 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../roborock/snapshots/test_diagnostics.ambr | 26 ++++++++++++++++ 13 files changed, 77 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index a8f6a6fbb4f..203f981e51d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -26,7 +26,7 @@ from .device import RoborockCoordinatedEntity class RoborockBinarySensorDescriptionMixin: """A class that describes binary sensor entities.""" - value_fn: Callable[[DeviceProp], bool] + value_fn: Callable[[DeviceProp], bool | int | None] @dataclass diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index dd4ef9e052f..30bfc71ea48 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -33,7 +33,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): device: HomeDataDevice, device_networking: NetworkInfo, product_info: HomeDataProduct, - cloud_api: RoborockMqttClient | None = None, + cloud_api: RoborockMqttClient, ) -> None: """Initialize.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) @@ -44,7 +44,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): DeviceProp(), ) device_data = DeviceData(device, product_info.model, device_networking.ip) - self.api = RoborockLocalClient(device_data) + self.api: RoborockLocalClient | RoborockMqttClient = RoborockLocalClient( + device_data + ) self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, @@ -59,18 +61,18 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" - try: - await self.api.ping() - except RoborockException: - if isinstance(self.api, RoborockLocalClient): + if isinstance(self.api, RoborockLocalClient): + try: + await self.api.ping() + except RoborockException: _LOGGER.warning( "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", self.roborock_device_info.device.duid, ) # We use the cloud api if the local api fails to connect. self.api = self.cloud_api - # Right now this should never be called if the cloud api is the primary api, - # but in the future if it is, a new else should be added. + # Right now this should never be called if the cloud api is the primary api, + # but in the future if it is, a new else should be added. async def release(self) -> None: """Disconnect from API.""" diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 2b005ecade6..c8f45b40d82 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -36,7 +36,7 @@ class RoborockEntity(Entity): def get_cache(self, attribute: CacheableAttribute) -> AttributeCache: """Get an item from the api cache.""" - return self._api.cache.get(attribute) + return self._api.cache[attribute] async def send( self, @@ -45,7 +45,7 @@ class RoborockEntity(Entity): ) -> dict: """Send a command to a vacuum cleaner.""" try: - response = await self._api.send_command(command, params) + response: dict = await self._api.send_command(command, params) except RoborockException as err: raise HomeAssistantError( f"Error while calling {command.name if isinstance(command, RoborockCommand) else command} with {params}" @@ -80,15 +80,11 @@ class RoborockCoordinatedEntity( def _device_status(self) -> Status: """Return the status of the device.""" data = self.coordinator.data - if data: - status = data.status - if status: - return status - return Status({}) + return data.status async def send( self, - command: RoborockCommand, + command: RoborockCommand | str, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Overloads normal send command but refreshes coordinator.""" diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 5be48c1f4bf..93f3f18f5fe 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.35.0"] + "requirements": ["python-roborock==0.36.0"] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 4eaf1464f89..d91606418d9 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -74,7 +74,7 @@ async def async_setup_entry( # We need to check if this function is supported by the device. results = await asyncio.gather( *( - coordinator.api.cache.get(description.cache_key).async_value() + coordinator.api.get_from_cache(description.cache_key) for coordinator, description in possible_entities ), return_exceptions=True, diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 5cf71bb12f4..f4968bf7db9 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -24,9 +24,9 @@ class RoborockSelectDescriptionMixin: # The command that the select entity will send to the api. api_command: RoborockCommand # Gets the current value of the select entity. - value_fn: Callable[[Status], str] + value_fn: Callable[[Status], str | None] # Gets all options of the select entity. - options_lambda: Callable[[Status], list[str]] + options_lambda: Callable[[Status], list[str] | None] # Takes the value from the select entiy and converts it for the api. parameter_lambda: Callable[[str, Status], list[int]] @@ -43,21 +43,23 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda data: data.water_box_mode.name, + value_fn=lambda data: data.water_box_mode_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda data: data.water_box_mode.keys() - if data.water_box_mode + if data.water_box_mode is not None else None, - parameter_lambda=lambda key, status: [status.water_box_mode.as_dict().get(key)], + parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)], ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda data: data.mop_mode.name, + value_fn=lambda data: data.mop_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.mop_mode.keys() if data.mop_mode else None, - parameter_lambda=lambda key, status: [status.mop_mode.as_dict().get(key)], + options_lambda=lambda data: data.mop_mode.keys() + if data.mop_mode is not None + else None, + parameter_lambda=lambda key, status: [status.get_mop_mode_code(key)], ), ] @@ -74,13 +76,15 @@ async def async_setup_entry( ] async_add_entities( RoborockSelectEntity( - f"{description.key}_{slugify(device_id)}", - coordinator, - description, + f"{description.key}_{slugify(device_id)}", coordinator, description, options ) for device_id, coordinator in coordinators.items() for description in SELECT_DESCRIPTIONS - if description.options_lambda(coordinator.roborock_device_info.props.status) + if ( + options := description.options_lambda( + coordinator.roborock_device_info.props.status + ) + ) is not None ) @@ -95,11 +99,12 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSelectDescription, + options: list[str], ) -> None: """Create a select entity.""" self.entity_description = entity_description super().__init__(unique_id, coordinator) - self._attr_options = self.entity_description.options_lambda(self._device_status) + self._attr_options = options async def async_select_option(self, option: str) -> None: """Set the option.""" diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 113e02e4abe..090ab2f233c 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -117,7 +117,7 @@ SENSOR_DESCRIPTIONS = [ icon="mdi:information-outline", device_class=SensorDeviceClass.ENUM, translation_key="status", - value_fn=lambda data: data.status.state.name, + value_fn=lambda data: data.status.state_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockStateCode.keys(), ), @@ -142,7 +142,7 @@ SENSOR_DESCRIPTIONS = [ icon="mdi:alert-circle", translation_key="vacuum_error", device_class=SensorDeviceClass.ENUM, - value_fn=lambda data: data.status.error_code.name, + value_fn=lambda data: data.status.error_code_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockErrorCode.keys(), ), @@ -157,7 +157,9 @@ SENSOR_DESCRIPTIONS = [ key="last_clean_start", translation_key="last_clean_start", icon="mdi:clock-time-twelve", - value_fn=lambda data: data.last_clean_record.begin_datetime, + value_fn=lambda data: data.last_clean_record.begin_datetime + if data.last_clean_record is not None + else None, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, ), @@ -165,7 +167,9 @@ SENSOR_DESCRIPTIONS = [ key="last_clean_end", translation_key="last_clean_end", icon="mdi:clock-time-twelve", - value_fn=lambda data: data.last_clean_record.end_datetime, + value_fn=lambda data: data.last_clean_record.end_datetime + if data.last_clean_record is not None + else None, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, ), diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index de820ede8fa..3dd7307da72 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -125,7 +125,7 @@ async def async_setup_entry( # We need to check if this function is supported by the device. results = await asyncio.gather( *( - coordinator.api.cache.get(description.cache_key).async_value() + coordinator.api.get_from_cache(description.cache_key) for coordinator, description in possible_entities ), return_exceptions=True, diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 5dc98e09352..d02d63597ac 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -139,7 +139,7 @@ async def async_setup_entry( # We need to check if this function is supported by the device. results = await asyncio.gather( *( - coordinator.api.cache.get(description.cache_key).async_value() + coordinator.api.get_from_cache(description.cache_key) for coordinator, description in possible_entities ), return_exceptions=True, diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 804c0826578..0edd8e3ec5a 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -93,11 +93,12 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): """Initialize a vacuum.""" StateVacuumEntity.__init__(self) RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) - self._attr_fan_speed_list = self._device_status.fan_power.keys() + self._attr_fan_speed_list = self._device_status.fan_power_options @property def state(self) -> str | None: """Return the status of the vacuum cleaner.""" + assert self._device_status.state is not None return STATE_CODE_TO_STATE.get(self._device_status.state) @property @@ -108,7 +109,7 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self._device_status.fan_power.name + return self._device_status.fan_power_name async def async_start(self) -> None: """Start the vacuum.""" @@ -138,7 +139,7 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): """Set vacuum fan speed.""" await self.send( RoborockCommand.SET_CUSTOM_MODE, - [self._device_status.fan_power.as_dict().get(fan_speed)], + [self._device_status.get_fan_speed_code(fan_speed)], ) async def async_start_pause(self) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index b0da7f8d209..6faa18cea85 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.35.0 +python-roborock==0.36.0 # homeassistant.components.smarttub python-smarttub==0.0.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 906919a3129..d341c4fca42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1628,7 +1628,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.35.0 +python-roborock==0.36.0 # homeassistant.components.smarttub python-smarttub==0.0.35 diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index d8e5f7d4cb2..6d851e41bce 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -260,7 +260,17 @@ 'dockType': 3, 'dustCollectionStatus': 0, 'errorCode': 0, + 'errorCodeName': 'none', 'fanPower': 102, + 'fanPowerName': 'balanced', + 'fanPowerOptions': list([ + 'off', + 'quiet', + 'balanced', + 'turbo', + 'max', + 'custom', + ]), 'homeSecEnablePassword': 0, 'homeSecStatus': 0, 'inCleaning': 0, @@ -274,10 +284,12 @@ 'mapStatus': 3, 'mopForbiddenEnable': 1, 'mopMode': 300, + 'mopModeName': 'standard', 'msgSeq': 458, 'msgVer': 2, 'squareMeterCleanArea': 21.0, 'state': 8, + 'stateName': 'charging', 'switchMapMode': 0, 'unsaveMapFlag': 0, 'unsaveMapReason': 0, @@ -285,6 +297,7 @@ 'washReady': 0, 'waterBoxCarriageStatus': 1, 'waterBoxMode': 203, + 'waterBoxModeName': 'intense', 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), @@ -521,7 +534,17 @@ 'dockType': 3, 'dustCollectionStatus': 0, 'errorCode': 0, + 'errorCodeName': 'none', 'fanPower': 102, + 'fanPowerName': 'balanced', + 'fanPowerOptions': list([ + 'off', + 'quiet', + 'balanced', + 'turbo', + 'max', + 'custom', + ]), 'homeSecEnablePassword': 0, 'homeSecStatus': 0, 'inCleaning': 0, @@ -535,10 +558,12 @@ 'mapStatus': 3, 'mopForbiddenEnable': 1, 'mopMode': 300, + 'mopModeName': 'standard', 'msgSeq': 458, 'msgVer': 2, 'squareMeterCleanArea': 21.0, 'state': 8, + 'stateName': 'charging', 'switchMapMode': 0, 'unsaveMapFlag': 0, 'unsaveMapReason': 0, @@ -546,6 +571,7 @@ 'washReady': 0, 'waterBoxCarriageStatus': 1, 'waterBoxMode': 203, + 'waterBoxModeName': 'intense', 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), From d913508607021350d6ccd8dd0c9c796ff83d0c53 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Nov 2023 12:50:40 +0100 Subject: [PATCH 341/982] Allow removing an entity more than once (#102904) --- homeassistant/helpers/entity.py | 25 ++++++++--- tests/helpers/test_entity.py | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 0948e1ef808..7877ca0e613 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -311,6 +311,8 @@ class Entity(ABC): # and removes the need for constant None checks or asserts. _state_info: StateInfo = None # type: ignore[assignment] + __remove_event: asyncio.Event | None = None + # Entity Properties _attr_assumed_state: bool = False _attr_attribution: str | None = None @@ -1022,6 +1024,7 @@ class Entity(ABC): await self.async_added_to_hass() self.async_write_ha_state() + @final async def async_remove(self, *, force_remove: bool = False) -> None: """Remove entity from Home Assistant. @@ -1032,12 +1035,19 @@ class Entity(ABC): If the entity doesn't have a non disabled entry in the entity registry, or if force_remove=True, its state will be removed. """ - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if self.platform and self._platform_state != EntityPlatformState.ADDED: - raise HomeAssistantError( - f"Entity '{self.entity_id}' async_remove called twice" - ) + if self.__remove_event is not None: + await self.__remove_event.wait() + return + + self.__remove_event = asyncio.Event() + try: + await self.__async_remove_impl(force_remove) + finally: + self.__remove_event.set() + + @final + async def __async_remove_impl(self, force_remove: bool) -> None: + """Remove entity from Home Assistant.""" self._platform_state = EntityPlatformState.REMOVED @@ -1156,6 +1166,9 @@ class Entity(ABC): await self.async_remove(force_remove=True) self.entity_id = registry_entry.entity_id + + # Clear the remove event to handle entity added again after entity id change + self.__remove_event = None self._platform_state = EntityPlatformState.NOT_ADDED await self.platform.async_add_entities([self]) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index a3ba5e48641..373dfac0cea 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -621,6 +621,34 @@ async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> No assert hass.states.get("test.test") is None +async def test_async_remove_twice(hass: HomeAssistant) -> None: + """Test removing an entity twice only cleans up once.""" + result = [] + + class MockEntity(entity.Entity): + def __init__(self) -> None: + self.remove_calls = [] + + async def async_will_remove_from_hass(self): + self.remove_calls.append(None) + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity() + ent.hass = hass + ent.entity_id = "test.test" + ent.async_on_remove(lambda: result.append(1)) + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == STATE_UNKNOWN + + await ent.async_remove() + assert len(result) == 1 + assert len(ent.remove_calls) == 1 + + await ent.async_remove() + assert len(result) == 1 + assert len(ent.remove_calls) == 1 + + async def test_set_context(hass: HomeAssistant) -> None: """Test setting context.""" context = Context() @@ -1590,3 +1618,51 @@ async def test_reuse_entity_object_after_entity_registry_disabled( match="Entity 'test.test_5678' cannot be added a second time", ): await platform.async_add_entities([ent]) + + +async def test_change_entity_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test changing entity id.""" + result = [] + + entry = entity_registry.async_get_or_create( + "test", "test_platform", "5678", suggested_object_id="test" + ) + assert entry.entity_id == "test.test" + + class MockEntity(entity.Entity): + _attr_unique_id = "5678" + + def __init__(self) -> None: + self.added_calls = [] + self.remove_calls = [] + + async def async_added_to_hass(self): + self.added_calls.append(None) + self.async_on_remove(lambda: result.append(1)) + + async def async_will_remove_from_hass(self): + self.remove_calls.append(None) + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity() + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == STATE_UNKNOWN + assert len(ent.added_calls) == 1 + + entry = entity_registry.async_update_entity( + entry.entity_id, new_entity_id="test.test2" + ) + await hass.async_block_till_done() + + assert len(result) == 1 + assert len(ent.added_calls) == 2 + assert len(ent.remove_calls) == 1 + + entity_registry.async_update_entity(entry.entity_id, new_entity_id="test.test3") + await hass.async_block_till_done() + + assert len(result) == 2 + assert len(ent.added_calls) == 3 + assert len(ent.remove_calls) == 2 From 24a65808acd23c16a86e2d0eb647cc54723d67fd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Nov 2023 13:08:41 +0100 Subject: [PATCH 342/982] Update black to 23.11.0 (#103644) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 77b16568eb4..5d43bcf1b02 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: args: - --fix - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.10.0 + rev: 23.11.0 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8891e6e210d..03c46de6b37 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.10.0 +black==23.11.0 codespell==2.2.2 ruff==0.1.1 yamllint==1.32.0 From 5bb3c7ca55bca73b42d04e850b8a64a61621bb32 Mon Sep 17 00:00:00 2001 From: Hessel Date: Wed, 8 Nov 2023 13:13:11 +0100 Subject: [PATCH 343/982] Wallbox Add Authentication Decorator (#102520) --- .../components/wallbox/coordinator.py | 114 +++++++++--------- tests/components/wallbox/test_number.py | 4 +- tests/components/wallbox/test_switch.py | 6 +- 3 files changed, 64 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index b9248d8ce5b..b3c5a9b4910 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -1,17 +1,18 @@ """DataUpdateCoordinator for the wallbox integration.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta from http import HTTPStatus import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar import requests from wallbox import Wallbox from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CHARGER_CURRENCY_KEY, @@ -62,6 +63,29 @@ CHARGER_STATUS: dict[int, ChargerStatus] = { 210: ChargerStatus.LOCKED_CAR_CONNECTED, } +_WallboxCoordinatorT = TypeVar("_WallboxCoordinatorT", bound="WallboxCoordinator") +_P = ParamSpec("_P") + + +def _require_authentication( + func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any] +) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]: + """Authenticate with decorator using Wallbox API.""" + + def require_authentication( + self: _WallboxCoordinatorT, *args: _P.args, **kwargs: _P.kwargs + ) -> Any: + """Authenticate using Wallbox API.""" + try: + self.authenticate() + return func(self, *args, **kwargs) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: + raise ConfigEntryAuthFailed from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + return require_authentication + class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" @@ -78,15 +102,9 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - def _authenticate(self) -> None: + def authenticate(self) -> None: """Authenticate using Wallbox API.""" - try: - self._wallbox.authenticate() - - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise ConfigEntryAuthFailed from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + self._wallbox.authenticate() def _validate(self) -> None: """Authenticate using Wallbox API.""" @@ -101,47 +119,41 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get new sensor data for Wallbox component.""" await self.hass.async_add_executor_job(self._validate) + @_require_authentication def _get_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" - try: - self._authenticate() - data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) - data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_CHARGING_CURRENT_KEY - ] - data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_LOCKED_UNLOCKED_KEY - ] - data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_ENERGY_PRICE_KEY - ] - data[ - CHARGER_CURRENCY_KEY - ] = f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" + data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_CHARGING_CURRENT_KEY + ] + data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_LOCKED_UNLOCKED_KEY + ] + data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_ENERGY_PRICE_KEY + ] + data[ + CHARGER_CURRENCY_KEY + ] = f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" - data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( - data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN - ) - return data - except ( - ConnectionError, - requests.exceptions.HTTPError, - ) as wallbox_connection_error: - raise UpdateFailed from wallbox_connection_error + data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( + data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN + ) + return data async def _async_update_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" return await self.hass.async_add_executor_job(self._get_data) + @_require_authentication def _set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" try: - self._authenticate() self._wallbox.setMaxChargingCurrent(self._station, charging_current) except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + raise wallbox_connection_error async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" @@ -150,25 +162,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) await self.async_request_refresh() + @_require_authentication def _set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - try: - self._authenticate() - self._wallbox.setEnergyCost(self._station, energy_cost) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + + self._wallbox.setEnergyCost(self._station, energy_cost) async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) await self.async_request_refresh() + @_require_authentication def _set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" try: - self._authenticate() if lock: self._wallbox.lockCharger(self._station) else: @@ -176,25 +184,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + raise wallbox_connection_error async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" await self.hass.async_add_executor_job(self._set_lock_unlock, lock) await self.async_request_refresh() + @_require_authentication def _pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" - try: - self._authenticate() - if pause: - self._wallbox.pauseChargingSession(self._station) - else: - self._wallbox.resumeChargingSession(self._station) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + + if pause: + self._wallbox.pauseChargingSession(self._station) + else: + self._wallbox.resumeChargingSession(self._station) async def async_pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 41ebedc91da..738b9bf7bd6 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -9,9 +9,9 @@ from homeassistant.components.wallbox.const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, ) -from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from . import ( authorisation_response, @@ -186,7 +186,7 @@ async def test_wallbox_number_class_energy_price_auth_error( status_code=403, ) - with pytest.raises(InvalidAuth): + with pytest.raises(ConfigEntryAuthFailed): await hass.services.async_call( "number", SERVICE_SET_VALUE, diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index 9418b4d8765..edd85c6ccc7 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -6,9 +6,9 @@ import requests_mock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY -from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from . import authorisation_response, setup_integration from .const import MOCK_SWITCH_ENTITY_ID @@ -120,7 +120,7 @@ async def test_wallbox_switch_class_authentication_error( status_code=403, ) - with pytest.raises(InvalidAuth): + with pytest.raises(ConfigEntryAuthFailed): await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -129,7 +129,7 @@ async def test_wallbox_switch_class_authentication_error( }, blocking=True, ) - with pytest.raises(InvalidAuth): + with pytest.raises(ConfigEntryAuthFailed): await hass.services.async_call( "switch", SERVICE_TURN_OFF, From db97e7588b627fbb731a33f3a72defdb819a39d3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 8 Nov 2023 14:10:24 +0100 Subject: [PATCH 344/982] Fix entity category for binary_sensor fails setup (#103511) --- .../components/mqtt/binary_sensor.py | 3 +++ tests/components/mqtt/test_init.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 7608fc5816c..9143b804c60 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -42,6 +42,7 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, + validate_sensor_entity_category, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -68,10 +69,12 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) DISCOVERY_SCHEMA = vol.All( + validate_sensor_entity_category(binary_sensor.DOMAIN, discovery=True), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) PLATFORM_SCHEMA_MODERN = vol.All( + validate_sensor_entity_category(binary_sensor.DOMAIN, discovery=False), _PLATFORM_SCHEMA_BASE, ) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 52c35d380f9..5bb86662322 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2172,6 +2172,18 @@ async def test_setup_manual_mqtt_with_invalid_config( }, "sensor.test", ), + ( + { + mqtt.DOMAIN: { + "binary_sensor": { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } + } + }, + "binary_sensor.test", + ), ], ) @patch( @@ -2195,6 +2207,14 @@ async def test_setup_manual_mqtt_with_invalid_entity_category( @pytest.mark.parametrize( ("config", "entity_id"), [ + ( + { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + }, + "binary_sensor.test", + ), ( { "name": "test", From 241e8560e99da4cbd3c9f763d77cfbd8badb1c66 Mon Sep 17 00:00:00 2001 From: ccrepin <55658384+ccrepin@users.noreply.github.com> Date: Wed, 8 Nov 2023 14:46:49 +0100 Subject: [PATCH 345/982] Change NP, NO code to Disarmed (#103617) --- homeassistant/components/sia/alarm_control_panel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index c59150266d9..149a0427ed0 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -52,6 +52,8 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription( "CQ": STATE_ALARM_ARMED_AWAY, "CS": STATE_ALARM_ARMED_AWAY, "CF": STATE_ALARM_ARMED_CUSTOM_BYPASS, + "NP": STATE_ALARM_DISARMED, + "NO": STATE_ALARM_DISARMED, "OA": STATE_ALARM_DISARMED, "OB": STATE_ALARM_DISARMED, "OG": STATE_ALARM_DISARMED, @@ -64,8 +66,6 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription( "NE": STATE_ALARM_ARMED_CUSTOM_BYPASS, "NF": STATE_ALARM_ARMED_CUSTOM_BYPASS, "BR": PREVIOUS_STATE, - "NP": PREVIOUS_STATE, - "NO": PREVIOUS_STATE, }, ) From 5901f6f7e730e9b43bf67fa939263e064c5b24f3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 8 Nov 2023 16:55:09 +0100 Subject: [PATCH 346/982] Move met coordinator to own module (#103546) --- homeassistant/components/met/__init__.py | 120 +------------------ homeassistant/components/met/coordinator.py | 126 ++++++++++++++++++++ homeassistant/components/met/weather.py | 2 +- tests/components/met/__init__.py | 2 +- tests/components/met/test_config_flow.py | 4 +- 5 files changed, 134 insertions(+), 120 deletions(-) create mode 100644 homeassistant/components/met/coordinator.py diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 53764252043..a10a07b5374 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -1,29 +1,12 @@ """The met component.""" from __future__ import annotations -from collections.abc import Callable -from datetime import timedelta import logging -from random import randrange -from types import MappingProxyType -from typing import Any, Self - -import metno from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ELEVATION, - CONF_LATITUDE, - CONF_LONGITUDE, - EVENT_CORE_CONFIG_UPDATE, - Platform, -) -from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util from .const import ( CONF_TRACK_HOME, @@ -31,9 +14,7 @@ from .const import ( DEFAULT_HOME_LONGITUDE, DOMAIN, ) - -# Dedicated Home Assistant endpoint - do not change! -URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/complete" +from .coordinator import MetDataUpdateCoordinator PLATFORMS = [Platform.WEATHER] @@ -98,98 +79,3 @@ async def cleanup_old_device(hass: HomeAssistant) -> None: if device: _LOGGER.debug("Removing improper device %s", device.name) device_reg.async_remove_device(device.id) - - -class CannotConnect(HomeAssistantError): - """Unable to connect to the web site.""" - - -class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): - """Class to manage fetching Met data.""" - - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Initialize global Met data updater.""" - self._unsub_track_home: Callable[[], None] | None = None - self.weather = MetWeatherData(hass, config_entry.data) - self.weather.set_coordinates() - - update_interval = timedelta(minutes=randrange(55, 65)) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self) -> MetWeatherData: - """Fetch data from Met.""" - try: - return await self.weather.fetch_data() - except Exception as err: - raise UpdateFailed(f"Update failed: {err}") from err - - def track_home(self) -> None: - """Start tracking changes to HA home setting.""" - if self._unsub_track_home: - return - - async def _async_update_weather_data(_event: Event | None = None) -> None: - """Update weather data.""" - if self.weather.set_coordinates(): - await self.async_refresh() - - self._unsub_track_home = self.hass.bus.async_listen( - EVENT_CORE_CONFIG_UPDATE, _async_update_weather_data - ) - - def untrack_home(self) -> None: - """Stop tracking changes to HA home setting.""" - if self._unsub_track_home: - self._unsub_track_home() - self._unsub_track_home = None - - -class MetWeatherData: - """Keep data for Met.no weather entities.""" - - def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: - """Initialise the weather entity data.""" - self.hass = hass - self._config = config - self._weather_data: metno.MetWeatherData - self.current_weather_data: dict = {} - self.daily_forecast: list[dict] = [] - self.hourly_forecast: list[dict] = [] - self._coordinates: dict[str, str] | None = None - - def set_coordinates(self) -> bool: - """Weather data initialization - set the coordinates.""" - if self._config.get(CONF_TRACK_HOME, False): - latitude = self.hass.config.latitude - longitude = self.hass.config.longitude - elevation = self.hass.config.elevation - else: - latitude = self._config[CONF_LATITUDE] - longitude = self._config[CONF_LONGITUDE] - elevation = self._config[CONF_ELEVATION] - - coordinates = { - "lat": str(latitude), - "lon": str(longitude), - "msl": str(elevation), - } - if coordinates == self._coordinates: - return False - self._coordinates = coordinates - - self._weather_data = metno.MetWeatherData( - coordinates, async_get_clientsession(self.hass), api_url=URL - ) - return True - - async def fetch_data(self) -> Self: - """Fetch data from API - (current weather and forecast).""" - resp = await self._weather_data.fetching_data() - if not resp: - raise CannotConnect() - self.current_weather_data = self._weather_data.get_current_weather() - time_zone = dt_util.DEFAULT_TIME_ZONE - self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) - self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) - return self diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py new file mode 100644 index 00000000000..6354e286cee --- /dev/null +++ b/homeassistant/components/met/coordinator.py @@ -0,0 +1,126 @@ +"""DataUpdateCoordinator for Met.no integration.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +import logging +from random import randrange +from types import MappingProxyType +from typing import Any, Self + +import metno + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, + EVENT_CORE_CONFIG_UPDATE, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_TRACK_HOME, DOMAIN + +# Dedicated Home Assistant endpoint - do not change! +URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/complete" + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Unable to connect to the web site.""" + + +class MetWeatherData: + """Keep data for Met.no weather entities.""" + + def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: + """Initialise the weather entity data.""" + self.hass = hass + self._config = config + self._weather_data: metno.MetWeatherData + self.current_weather_data: dict = {} + self.daily_forecast: list[dict] = [] + self.hourly_forecast: list[dict] = [] + self._coordinates: dict[str, str] | None = None + + def set_coordinates(self) -> bool: + """Weather data initialization - set the coordinates.""" + if self._config.get(CONF_TRACK_HOME, False): + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + elevation = self.hass.config.elevation + else: + latitude = self._config[CONF_LATITUDE] + longitude = self._config[CONF_LONGITUDE] + elevation = self._config[CONF_ELEVATION] + + coordinates = { + "lat": str(latitude), + "lon": str(longitude), + "msl": str(elevation), + } + if coordinates == self._coordinates: + return False + self._coordinates = coordinates + + self._weather_data = metno.MetWeatherData( + coordinates, async_get_clientsession(self.hass), api_url=URL + ) + return True + + async def fetch_data(self) -> Self: + """Fetch data from API - (current weather and forecast).""" + resp = await self._weather_data.fetching_data() + if not resp: + raise CannotConnect() + self.current_weather_data = self._weather_data.get_current_weather() + time_zone = dt_util.DEFAULT_TIME_ZONE + self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) + self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) + return self + + +class MetDataUpdateCoordinator(DataUpdateCoordinator[MetWeatherData]): + """Class to manage fetching Met data.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize global Met data updater.""" + self._unsub_track_home: Callable[[], None] | None = None + self.weather = MetWeatherData(hass, config_entry.data) + self.weather.set_coordinates() + + update_interval = timedelta(minutes=randrange(55, 65)) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self) -> MetWeatherData: + """Fetch data from Met.""" + try: + return await self.weather.fetch_data() + except Exception as err: + raise UpdateFailed(f"Update failed: {err}") from err + + def track_home(self) -> None: + """Start tracking changes to HA home setting.""" + if self._unsub_track_home: + return + + async def _async_update_weather_data(_event: Event | None = None) -> None: + """Update weather data.""" + if self.weather.set_coordinates(): + await self.async_refresh() + + self._unsub_track_home = self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, _async_update_weather_data + ) + + def untrack_home(self) -> None: + """Stop tracking changes to HA home setting.""" + if self._unsub_track_home: + self._unsub_track_home() + self._unsub_track_home = None diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 97b99e826cd..11b044311d2 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -36,7 +36,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM -from . import MetDataUpdateCoordinator from .const import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, @@ -46,6 +45,7 @@ from .const import ( DOMAIN, FORECAST_MAP, ) +from .coordinator import MetDataUpdateCoordinator DEFAULT_NAME = "Met.no" diff --git a/tests/components/met/__init__.py b/tests/components/met/__init__.py index 0a17b415965..2ef0f7e12f0 100644 --- a/tests/components/met/__init__.py +++ b/tests/components/met/__init__.py @@ -21,7 +21,7 @@ async def init_integration(hass, track_home=False) -> MockConfigEntry: entry = MockConfigEntry(domain=DOMAIN, data=entry_data) with patch( - "homeassistant.components.met.metno.MetWeatherData.fetching_data", + "homeassistant.components.met.coordinator.metno.MetWeatherData.fetching_data", return_value=True, ): entry.add_to_hass(hass) diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index 59ffff14f1b..24ce8660346 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -156,7 +156,9 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["step_id"] == "init" # Test Options flow updated config entry - with patch("homeassistant.components.met.metno.MetWeatherData") as weatherdatamock: + with patch( + "homeassistant.components.met.coordinator.metno.MetWeatherData" + ) as weatherdatamock: result = await hass.config_entries.options.async_init( entry.entry_id, data=update_data ) From cec617cfbb86a57bebd80d1e1492dfe0ec7dc11d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 8 Nov 2023 09:13:48 -0800 Subject: [PATCH 347/982] Add support for deleting To-do items in Google Tasks (#102967) * Add support for deleting To-do items in Google Tasks * Cleanup multipart test * Fix comments * Add additional error checking to increase coverage * Apply suggestions and fix tests --- homeassistant/components/google_tasks/api.py | 67 ++- .../components/google_tasks/exceptions.py | 7 + homeassistant/components/google_tasks/todo.py | 9 +- .../google_tasks/snapshots/test_todo.ambr | 6 + tests/components/google_tasks/test_todo.py | 394 +++++++++++++++++- 5 files changed, 470 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/google_tasks/exceptions.py diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index d42926c3bf6..5dd7156702f 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -1,18 +1,36 @@ """API for Google Tasks bound to Home Assistant OAuth.""" +import json +import logging from typing import Any from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build -from googleapiclient.http import HttpRequest +from googleapiclient.errors import HttpError +from googleapiclient.http import BatchHttpRequest, HttpRequest from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from .exceptions import GoogleTasksApiError + +_LOGGER = logging.getLogger(__name__) + MAX_TASK_RESULTS = 100 +def _raise_if_error(result: Any | dict[str, Any]) -> None: + """Raise a GoogleTasksApiError if the response contains an error.""" + if not isinstance(result, dict): + raise GoogleTasksApiError( + f"Google Tasks API replied with unexpected response: {result}" + ) + if error := result.get("error"): + message = error.get("message", "Unknown Error") + raise GoogleTasksApiError(f"Google Tasks API response: {message}") + + class AsyncConfigEntryAuth: """Provide Google Tasks authentication tied to an OAuth2 based config entry.""" @@ -40,7 +58,7 @@ class AsyncConfigEntryAuth: """Get all TaskList resources.""" service = await self._get_service() cmd: HttpRequest = service.tasklists().list() - result = await self._hass.async_add_executor_job(cmd.execute) + result = await self._execute(cmd) return result["items"] async def list_tasks(self, task_list_id: str) -> list[dict[str, Any]]: @@ -49,7 +67,7 @@ class AsyncConfigEntryAuth: cmd: HttpRequest = service.tasks().list( tasklist=task_list_id, maxResults=MAX_TASK_RESULTS ) - result = await self._hass.async_add_executor_job(cmd.execute) + result = await self._execute(cmd) return result["items"] async def insert( @@ -63,7 +81,7 @@ class AsyncConfigEntryAuth: tasklist=task_list_id, body=task, ) - await self._hass.async_add_executor_job(cmd.execute) + await self._execute(cmd) async def patch( self, @@ -78,4 +96,43 @@ class AsyncConfigEntryAuth: task=task_id, body=task, ) - await self._hass.async_add_executor_job(cmd.execute) + await self._execute(cmd) + + async def delete( + self, + task_list_id: str, + task_ids: list[str], + ) -> None: + """Delete a task resources.""" + service = await self._get_service() + batch: BatchHttpRequest = service.new_batch_http_request() + + def response_handler(_, response, exception: HttpError) -> None: + if exception is not None: + raise GoogleTasksApiError( + f"Google Tasks API responded with error ({exception.status_code})" + ) from exception + data = json.loads(response) + _raise_if_error(data) + + for task_id in task_ids: + batch.add( + service.tasks().delete( + tasklist=task_list_id, + task=task_id, + ), + request_id=task_id, + callback=response_handler, + ) + await self._execute(batch) + + async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any: + try: + result = await self._hass.async_add_executor_job(request.execute) + except HttpError as err: + raise GoogleTasksApiError( + f"Google Tasks API responded with error ({err.status_code})" + ) from err + if result: + _raise_if_error(result) + return result diff --git a/homeassistant/components/google_tasks/exceptions.py b/homeassistant/components/google_tasks/exceptions.py new file mode 100644 index 00000000000..406a3a69d51 --- /dev/null +++ b/homeassistant/components/google_tasks/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions for Google Tasks api calls.""" + +from homeassistant.exceptions import HomeAssistantError + + +class GoogleTasksApiError(HomeAssistantError): + """Error talking to the Google Tasks API.""" diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 5d2da33da71..01ceb0349e6 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -65,7 +65,9 @@ class GoogleTaskTodoListEntity( _attr_has_entity_name = True _attr_supported_features = ( - TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM ) def __init__( @@ -114,3 +116,8 @@ class GoogleTaskTodoListEntity( task=_convert_todo_item(item), ) await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete To-do items.""" + await self.coordinator.api.delete(self._task_list_id, uids) + await self.coordinator.async_refresh() diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index f24d17a60d1..98b59b7697b 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -8,6 +8,12 @@ # name: test_create_todo_list_item[api_responses0].1 '{"title": "Soda", "status": "needsAction"}' # --- +# name: test_delete_todo_list_item[_handler] + tuple( + 'https://tasks.googleapis.com/batch', + 'POST', + ) +# --- # name: test_partial_update_status[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index e19ac1272cd..7b11372f1d4 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable +from http import HTTPStatus import json from typing import Any from unittest.mock import Mock, patch @@ -13,6 +14,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator @@ -29,12 +31,30 @@ EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { "items": [], } +ERROR_RESPONSE = { + "error": { + "code": 400, + "message": "Invalid task ID", + "errors": [ + {"message": "Invalid task ID", "domain": "global", "reason": "invalid"} + ], + } +} +CONTENT_ID = "Content-ID" +BOUNDARY = "batch_00972cc8-75bd-11ee-9692-0242ac110002" # Arbitrary uuid LIST_TASKS_RESPONSE_WATER = { "items": [ {"id": "some-task-id", "title": "Water", "status": "needsAction"}, ], } +LIST_TASKS_RESPONSE_MULTIPLE = { + "items": [ + {"id": "some-task-id-1", "title": "Water", "status": "needsAction"}, + {"id": "some-task-id-2", "title": "Milk", "status": "needsAction"}, + {"id": "some-task-id-3", "title": "Cheese", "status": "needsAction"}, + ], +} @pytest.fixture @@ -88,14 +108,87 @@ def mock_api_responses() -> list[dict | list]: return [] +def create_response_object(api_response: dict | list) -> tuple[Response, bytes]: + """Create an http response.""" + return ( + Response({"Content-Type": "application/json"}), + json.dumps(api_response).encode(), + ) + + +def create_batch_response_object( + content_ids: list[str], api_responses: list[dict | list | Response] +) -> tuple[Response, bytes]: + """Create a batch response in the multipart/mixed format.""" + assert len(api_responses) == len(content_ids) + content = [] + for api_response in api_responses: + status = 200 + body = "" + if isinstance(api_response, Response): + status = api_response.status + else: + body = json.dumps(api_response) + content.extend( + [ + f"--{BOUNDARY}", + "Content-Type: application/http", + f"{CONTENT_ID}: {content_ids.pop()}", + "", + f"HTTP/1.1 {status} OK", + "Content-Type: application/json; charset=UTF-8", + "", + body, + ] + ) + content.append(f"--{BOUNDARY}--") + body = ("\r\n".join(content)).encode() + return ( + Response( + { + "Content-Type": f"multipart/mixed; boundary={BOUNDARY}", + "Content-ID": "1", + } + ), + body, + ) + + +def create_batch_response_handler( + api_responses: list[dict | list | Response], +) -> Callable[[Any], tuple[Response, bytes]]: + """Create a fake http2lib response handler that supports generating batch responses. + + Multi-part response objects are dynamically generated since they + need to match the Content-ID of the incoming request. + """ + + def _handler(url, method, **kwargs) -> tuple[Response, bytes]: + next_api_response = api_responses.pop(0) + if method == "POST" and (body := kwargs.get("body")): + content_ids = [ + line[len(CONTENT_ID) + 2 :] + for line in body.splitlines() + if line.startswith(f"{CONTENT_ID}:") + ] + if content_ids: + return create_batch_response_object(content_ids, next_api_response) + return create_response_object(next_api_response) + + return _handler + + +@pytest.fixture(name="response_handler") +def mock_response_handler(api_responses: list[dict | list]) -> list: + """Create a mock http2lib response handler.""" + return [create_response_object(api_response) for api_response in api_responses] + + @pytest.fixture(autouse=True) -def mock_http_response(api_responses: list[dict | list]) -> Mock: +def mock_http_response(response_handler: list | Callable) -> Mock: """Fixture to fake out http2lib responses.""" - responses = [ - (Response({}), bytes(json.dumps(api_response), encoding="utf-8")) - for api_response in api_responses - ] - with patch("httplib2.Http.request", side_effect=responses) as mock_response: + + with patch("httplib2.Http.request", side_effect=response_handler) as mock_response: yield mock_response @@ -146,6 +239,29 @@ async def test_get_items( assert state.state == "1" +@pytest.mark.parametrize( + "response_handler", + [ + ([(Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b"")]), + ], +) +async def test_list_items_server_error( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test an error returned by the server when setting up the platform.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + state = hass.states.get("todo.my_tasks") + assert state is None + + @pytest.mark.parametrize( "api_responses", [ @@ -176,6 +292,33 @@ async def test_empty_todo_list( assert state.state == "0" +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + ERROR_RESPONSE, + ] + ], +) +async def test_task_items_error_response( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test an error while getting todo list items.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "unavailable" + + @pytest.mark.parametrize( "api_responses", [ @@ -183,7 +326,7 @@ async def test_empty_todo_list( LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE, EMPTY_RESPONSE, # create - LIST_TASKS_RESPONSE, # refresh after create + LIST_TASKS_RESPONSE, # refresh after delete ] ], ) @@ -216,6 +359,41 @@ async def test_create_todo_list_item( assert call.kwargs.get("body") == snapshot +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + ERROR_RESPONSE, + ] + ], +) +async def test_create_todo_list_item_error( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Mock, + snapshot: SnapshotAssertion, +) -> None: + """Test for an error response when creating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + with pytest.raises(HomeAssistantError, match="Invalid task ID"): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Soda"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + @pytest.mark.parametrize( "api_responses", [ @@ -256,6 +434,41 @@ async def test_update_todo_list_item( assert call.kwargs.get("body") == snapshot +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + ERROR_RESPONSE, # update fails + ] + ], +) +async def test_update_todo_list_item_error( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for an error response when updating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + with pytest.raises(HomeAssistantError, match="Invalid task ID"): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "some-task-id", "rename": "Soda", "status": "completed"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + @pytest.mark.parametrize( "api_responses", [ @@ -334,3 +547,170 @@ async def test_partial_update_status( assert call assert call.args == snapshot assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "response_handler", + [ + ( + create_batch_response_handler( + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + [EMPTY_RESPONSE, EMPTY_RESPONSE, EMPTY_RESPONSE], # Delete batch + LIST_TASKS_RESPONSE, # refresh after create + ] + ) + ) + ], +) +async def test_delete_todo_list_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for deleting multiple To-do Items.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "3" + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["some-task-id-1", "some-task-id-2", "some-task-id-3"]}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + + +@pytest.mark.parametrize( + "response_handler", + [ + ( + create_batch_response_handler( + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + [ + EMPTY_RESPONSE, + ERROR_RESPONSE, # one item is a failure + EMPTY_RESPONSE, + ], + LIST_TASKS_RESPONSE, # refresh after create + ] + ) + ) + ], +) +async def test_delete_partial_failure( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for partial failure when deleting multiple To-do Items.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "3" + + with pytest.raises(HomeAssistantError, match="Invalid task ID"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["some-task-id-1", "some-task-id-2", "some-task-id-3"]}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "response_handler", + [ + ( + create_batch_response_handler( + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + [ + "1234-invalid-json", + ], + ] + ) + ) + ], +) +async def test_delete_invalid_json_response( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test delete with an invalid json response.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "3" + + with pytest.raises(HomeAssistantError, match="unexpected response"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["some-task-id-1"]}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "response_handler", + [ + ( + create_batch_response_handler( + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + [Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR})], + ] + ) + ) + ], +) +async def test_delete_server_error( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test delete with an invalid json response.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "3" + + with pytest.raises(HomeAssistantError, match="responded with error"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["some-task-id-1"]}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) From 1a51d863cf1d56e2cb3ac49aa42a95dc243e552a Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 8 Nov 2023 16:39:06 -0500 Subject: [PATCH 348/982] Bump Python-Roborock to 0.36.1 (#103662) bump to 0.36.1 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 93f3f18f5fe..ed043582a0e 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.36.0"] + "requirements": ["python-roborock==0.36.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6faa18cea85..5cff395d754 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.36.0 +python-roborock==0.36.1 # homeassistant.components.smarttub python-smarttub==0.0.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d341c4fca42..bc08c90e891 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1628,7 +1628,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.36.0 +python-roborock==0.36.1 # homeassistant.components.smarttub python-smarttub==0.0.35 From f511a8a26a4d866fd4020b9370b60f08c4bce7ba Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 8 Nov 2023 17:05:31 -0500 Subject: [PATCH 349/982] Expand `zwave_js.set_config_parameter` with additional parameters (#102092) --- homeassistant/components/zwave_js/const.py | 2 + homeassistant/components/zwave_js/services.py | 100 ++++++++-- .../components/zwave_js/services.yaml | 12 ++ .../components/zwave_js/strings.json | 10 +- tests/components/zwave_js/test_services.py | 184 +++++++++++++++++- 5 files changed, 289 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 5d4a8c574bf..acc1da4e51a 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -106,6 +106,8 @@ ATTR_NODES = "nodes" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_PARAMETER_BITMASK = "bitmask" ATTR_CONFIG_VALUE = "value" +ATTR_VALUE_SIZE = "value_size" +ATTR_VALUE_FORMAT = "value_format" # refresh value ATTR_REFRESH_ALL_VALUES = "refresh_all_values" # multicast diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 20485d8a922..12c1ed242af 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Generator, Sequence import logging +import math from typing import Any, TypeVar import voluptuous as vol @@ -13,7 +14,11 @@ from zwave_js_server.const.command_class.notification import NotificationType from zwave_js_server.exceptions import FailedZWaveCommand, SetValueFailed from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import ValueDataType, get_value_id_str +from zwave_js_server.model.value import ( + ConfigurationValueFormat, + ValueDataType, + get_value_id_str, +) from zwave_js_server.util.multicast import async_multicast_set_value from zwave_js_server.util.node import ( async_bulk_set_partial_config_parameters, @@ -58,6 +63,13 @@ def parameter_name_does_not_need_bitmask( return val +def check_base_2(val: int) -> int: + """Check if value is a power of 2.""" + if not math.log2(val).is_integer(): + raise vol.Invalid("Value must be a power of 2.") + return val + + def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: """Validate that the service call is for a broadcast command.""" if val.get(const.ATTR_BROADCAST): @@ -78,10 +90,10 @@ def get_valid_responses_from_results( def raise_exceptions_from_results( - zwave_objects: Sequence[ZwaveNode | Endpoint], - results: Sequence[Any], + zwave_objects: Sequence[T], results: Sequence[Any] ) -> None: """Raise list of exceptions from a list of results.""" + errors: Sequence[tuple[T, Any]] if errors := [ tup for tup in zip(zwave_objects, results) if isinstance(tup[1], Exception) ]: @@ -263,10 +275,19 @@ class ZWaveServices: vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( vol.Coerce(int), BITMASK_SCHEMA, cv.string ), + vol.Inclusive(const.ATTR_VALUE_SIZE, "raw"): vol.All( + vol.Coerce(int), vol.Range(min=1, max=4), check_base_2 + ), + vol.Inclusive(const.ATTR_VALUE_FORMAT, "raw"): vol.Coerce( + ConfigurationValueFormat + ), }, cv.has_at_least_one_key( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID ), + cv.has_at_most_one_key( + const.ATTR_CONFIG_PARAMETER_BITMASK, const.ATTR_VALUE_SIZE + ), parameter_name_does_not_need_bitmask, get_nodes_from_service_data, has_at_least_one_node, @@ -487,7 +508,33 @@ class ZWaveServices: property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) new_value = service.data[const.ATTR_CONFIG_VALUE] + value_size = service.data.get(const.ATTR_VALUE_SIZE) + value_format = service.data.get(const.ATTR_VALUE_FORMAT) + nodes_without_endpoints: set[ZwaveNode] = set() + # Remove nodes that don't have the specified endpoint + for node in nodes: + if endpoint not in node.endpoints: + nodes_without_endpoints.add(node) + nodes = nodes.difference(nodes_without_endpoints) + if not nodes: + raise HomeAssistantError( + "None of the specified nodes have the specified endpoint" + ) + if nodes_without_endpoints and _LOGGER.isEnabledFor(logging.WARNING): + _LOGGER.warning( + ( + "The following nodes do not have endpoint %x and will be " + "skipped: %s" + ), + endpoint, + nodes_without_endpoints, + ) + + # If value_size isn't provided, we will use the utility function which includes + # additional checks and protections. If it is provided, we will use the + # node.async_set_raw_config_parameter_value method which calls the + # Configuration CC set API. results = await asyncio.gather( *( async_set_config_parameter( @@ -497,23 +544,42 @@ class ZWaveServices: property_key=property_key, endpoint=endpoint, ) + if value_size is None + else node.endpoints[endpoint].async_set_raw_config_parameter_value( + new_value, + property_or_property_name, + property_key=property_key, + value_size=value_size, + value_format=value_format, + ) for node in nodes ), return_exceptions=True, ) - nodes_list = list(nodes) - for node, result in get_valid_responses_from_results(nodes_list, results): - zwave_value = result[0] - cmd_status = result[1] - if cmd_status == CommandStatus.ACCEPTED: - msg = "Set configuration parameter %s on Node %s with value %s" - else: - msg = ( - "Added command to queue to set configuration parameter %s on Node " - "%s with value %s. Parameter will be set when the device wakes up" - ) - _LOGGER.info(msg, zwave_value, node, new_value) - raise_exceptions_from_results(nodes_list, results) + + def process_results( + nodes_or_endpoints_list: list[T], _results: list[Any] + ) -> None: + """Process results for given nodes or endpoints.""" + for node_or_endpoint, result in get_valid_responses_from_results( + nodes_or_endpoints_list, _results + ): + zwave_value = result[0] + cmd_status = result[1] + if cmd_status.status == CommandStatus.ACCEPTED: + msg = "Set configuration parameter %s on Node %s with value %s" + else: + msg = ( + "Added command to queue to set configuration parameter %s on %s " + "with value %s. Parameter will be set when the device wakes up" + ) + _LOGGER.info(msg, zwave_value, node_or_endpoint, new_value) + raise_exceptions_from_results(nodes_or_endpoints_list, _results) + + if value_size is None: + process_results(list(nodes), results) + else: + process_results([node.endpoints[endpoint] for node in nodes], results) async def async_bulk_set_partial_config_parameters( self, service: ServiceCall @@ -605,7 +671,7 @@ class ZWaveServices: results = await asyncio.gather(*coros, return_exceptions=True) nodes_list = list(nodes) # multiple set_values my fail so we will track the entire list - set_value_failed_nodes_list: list[ZwaveNode | Endpoint] = [] + set_value_failed_nodes_list: list[ZwaveNode] = [] set_value_failed_error_list: list[SetValueFailed] = [] for node_, result in get_valid_responses_from_results(nodes_list, results): if result and result.status not in SET_VALUE_SUCCESS: diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index e21103aa22e..cb8e726bf32 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -54,6 +54,18 @@ set_config_parameter: required: true selector: text: + value_size: + example: 1 + selector: + number: + min: 1 + max: 4 + value_format: + example: 1 + selector: + number: + min: 0 + max: 3 bulk_set_partial_config_parameters: target: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 59cec0ed541..71c6b93e2bd 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -216,11 +216,19 @@ }, "bitmask": { "name": "Bitmask", - "description": "Target a specific bitmask (see the documentation for more information)." + "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format." }, "value": { "name": "Value", "description": "The new value to set for this configuration parameter." + }, + "value_size": { + "name": "Value size", + "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." + }, + "value_format": { + "name": "Value format", + "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." } } }, diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index f5b7809d8cc..8697dad2e7b 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest import voluptuous as vol from zwave_js_server.exceptions import FailedZWaveCommand +from zwave_js_server.model.value import SetConfigParameterResult from homeassistant.components.group import Group from homeassistant.components.zwave_js.const import ( @@ -22,6 +23,8 @@ from homeassistant.components.zwave_js.const import ( ATTR_PROPERTY_KEY, ATTR_REFRESH_ALL_VALUES, ATTR_VALUE, + ATTR_VALUE_FORMAT, + ATTR_VALUE_SIZE, ATTR_WAIT_FOR_RESULT, DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -56,7 +59,12 @@ from tests.common import MockConfigEntry async def test_set_config_parameter( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + client, + multisensor_6, + aeotec_zw164_siren, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the set_config_parameter service.""" dev_reg = async_get_dev_reg(hass) @@ -225,6 +233,63 @@ async def test_set_config_parameter( client.async_send_command_no_wait.reset_mock() + # Test setting parameter by value_size + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 2, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.set_raw_config_parameter_value" + assert args["nodeId"] == 52 + assert args["endpoint"] == 0 + options = args["options"] + assert options["parameter"] == 2 + assert options["value"] == 1 + assert options["valueSize"] == 2 + assert options["valueFormat"] == 1 + + client.async_send_command_no_wait.reset_mock() + + # Test setting parameter when one node has endpoint and other doesn't + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: [AIR_TEMPERATURE_SENSOR, "siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 1, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.set_raw_config_parameter_value" + assert args["nodeId"] == 2 + assert args["endpoint"] == 1 + options = args["options"] + assert options["parameter"] == 32 + assert options["value"] == 1 + assert options["valueSize"] == 2 + assert options["valueFormat"] == 1 + + client.async_send_command_no_wait.reset_mock() + client.async_send_command.reset_mock() + # Test groups get expanded assert await async_setup_component(hass, "group", {}) await Group.async_create_group( @@ -296,6 +361,54 @@ async def test_set_config_parameter( config_entry=non_zwave_js_config_entry, ) + # Test unknown endpoint throws error when None are remaining + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_ENDPOINT: 5, + ATTR_CONFIG_PARAMETER: 2, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + # Test that we can't include bitmask and value size and value format + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: "Fahrenheit", + ATTR_VALUE_FORMAT: 1, + ATTR_VALUE_SIZE: 2, + }, + blocking=True, + ) + + # Test that value size must be 1, 2, or 4 (not 3) + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: "Fahrenheit", + ATTR_VALUE_FORMAT: 1, + ATTR_VALUE_SIZE: 3, + }, + blocking=True, + ) + # Test that a Z-Wave JS device with an invalid node ID, non Z-Wave JS entity, # non Z-Wave JS device, invalid device_id, and invalid node_id gets filtered out. await hass.services.async_call( @@ -376,6 +489,75 @@ async def test_set_config_parameter( blocking=True, ) + client.async_send_command_no_wait.reset_mock() + client.async_send_command.reset_mock() + + caplog.clear() + + config_value = aeotec_zw164_siren.values["2-112-0-32"] + cmd_result = SetConfigParameterResult("accepted", {"status": 255}) + + # Test accepted return + with patch( + "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", + return_value=(config_value, cmd_result), + ) as mock_set_raw_config_parameter_value: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: ["siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 0, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + assert len(mock_set_raw_config_parameter_value.call_args_list) == 1 + assert mock_set_raw_config_parameter_value.call_args[0][0] == 1 + assert mock_set_raw_config_parameter_value.call_args[0][1] == 32 + assert mock_set_raw_config_parameter_value.call_args[1] == { + "property_key": None, + "value_size": 2, + "value_format": 1, + } + + assert "Set configuration parameter" in caplog.text + caplog.clear() + + # Test queued return + cmd_result.status = "queued" + with patch( + "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", + return_value=(config_value, cmd_result), + ) as mock_set_raw_config_parameter_value: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: ["siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 0, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + assert len(mock_set_raw_config_parameter_value.call_args_list) == 1 + assert mock_set_raw_config_parameter_value.call_args[0][0] == 1 + assert mock_set_raw_config_parameter_value.call_args[0][1] == 32 + assert mock_set_raw_config_parameter_value.call_args[1] == { + "property_key": None, + "value_size": 2, + "value_format": 1, + } + + assert "Added command to queue" in caplog.text + caplog.clear() + async def test_set_config_parameter_gather( hass: HomeAssistant, From 123f14dd6cf3d6fe811459bff48ab189c11129d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Nov 2023 00:06:04 +0100 Subject: [PATCH 350/982] Attach correct platform config in check_config warnings and errors (#103633) --- homeassistant/helpers/check_config.py | 4 +- tests/helpers/test_check_config.py | 75 ++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index f65cd4e359e..c333bab782b 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -243,7 +243,7 @@ async def async_check_ha_config_file( # noqa: C901 try: p_validated = component_platform_schema(p_config) except vol.Invalid as ex: - _comp_error(ex, domain, config) + _comp_error(ex, domain, p_config) continue # Not all platform components follow same pattern for platforms @@ -279,7 +279,7 @@ async def async_check_ha_config_file( # noqa: C901 try: p_validated = platform_schema(p_validated) except vol.Invalid as ex: - _comp_error(ex, f"{domain}.{p_name}", p_validated) + _comp_error(ex, f"{domain}.{p_name}", p_config) continue platforms.append(p_validated) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 197fb88695f..38c1b4913cd 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -3,6 +3,7 @@ import logging from unittest.mock import Mock, patch import pytest +import voluptuous as vol from homeassistant.config import YAML_CONFIG_FILE from homeassistant.core import HomeAssistant @@ -14,7 +15,13 @@ from homeassistant.helpers.check_config import ( import homeassistant.helpers.config_validation as cv from homeassistant.requirements import RequirementsNotFound -from tests.common import MockModule, mock_integration, mock_platform, patch_yaml_files +from tests.common import ( + MockModule, + MockPlatform, + mock_integration, + mock_platform, + patch_yaml_files, +) _LOGGER = logging.getLogger(__name__) @@ -255,6 +262,72 @@ async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: _assert_warnings_errors(res, [], []) +@pytest.mark.parametrize( + ("extra_config", "warnings", "message", "config"), + [ + ( + "blah:\n - platform: test\n option1: abc", + 0, + None, + None, + ), + ( + "blah:\n - platform: test\n option1: 123", + 1, + "Invalid config for [blah.test]: expected str for dictionary value", + {"option1": 123, "platform": "test"}, + ), + # Test the attached config is unvalidated (key old is removed by validator) + ( + "blah:\n - platform: test\n old: blah\n option1: 123", + 1, + "Invalid config for [blah.test]: expected str for dictionary value", + {"old": "blah", "option1": 123, "platform": "test"}, + ), + # Test base platform configuration error + ( + "blah:\n - paltfrom: test\n", + 1, + "Invalid config for [blah]: required key not provided", + {"paltfrom": "test"}, + ), + ], +) +async def test_component_platform_schema_error( + hass: HomeAssistant, + extra_config: str, + warnings: int, + message: str | None, + config: dict | None, +) -> None: + """Test schema error in component.""" + comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) + mock_integration( + hass, + MockModule("blah", platform_schema_base=comp_platform_schema_base), + ) + test_platform_schema = comp_platform_schema.extend({"option1": str}) + mock_platform( + hass, + "test.blah", + MockPlatform(platform_schema=test_platform_schema), + ) + + files = {YAML_CONFIG_FILE: BASE_CONFIG + extra_config} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert len(res.errors) == 0 + assert len(res.warnings) == warnings + + for warn in res.warnings: + assert message in warn.message + assert warn.config == config + + async def test_component_config_platform_import_error(hass: HomeAssistant) -> None: """Test errors if config platform fails to import.""" # Make sure they don't exist From dc7d8173988f20b302c1eaefd2e5f01f84d6bc8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Nov 2023 18:13:11 -0600 Subject: [PATCH 351/982] Incease tplink setup timeout (#103671) It can take longer than 5s to do the first update of the device especially when the device is overloaded as seen in #103668 Wait 10 seconds for the discovery since when the power strips are loaded they cannot respond in time --- homeassistant/components/tplink/__init__.py | 2 +- tests/components/tplink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index d8285cbed70..f2a1e682304 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -87,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" host = entry.data[CONF_HOST] try: - device: SmartDevice = await Discover.discover_single(host) + device: SmartDevice = await Discover.discover_single(host, timeout=10) except SmartDeviceException as ex: raise ConfigEntryNotReady from ex diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 816251ae3bb..9006a058c57 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -202,7 +202,7 @@ def _patch_discovery(device=None, no_device=False): def _patch_single_discovery(device=None, no_device=False): - async def _discover_single(*_): + async def _discover_single(*args, **kwargs): if no_device: raise SmartDeviceException return device if device else _mocked_bulb() From 78add0f51d356d7374e3f8b23ad86b5005e83e03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Nov 2023 01:47:37 -0600 Subject: [PATCH 352/982] Bump aioesphomeapi to 18.2.7 (#103676) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index cb1a741c447..ff9ac3da6e1 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.2.4", + "aioesphomeapi==18.2.7", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5cff395d754..14f8fa41b7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.4 +aioesphomeapi==18.2.7 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc08c90e891..f916f35beb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.4 +aioesphomeapi==18.2.7 # homeassistant.components.flo aioflo==2021.11.0 From 143e1145288532a2b94ee12757a208b5e051ab8b Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Thu, 9 Nov 2023 09:18:32 +0100 Subject: [PATCH 353/982] Improve AsusWRT integration tests (#102810) --- tests/components/asuswrt/common.py | 53 +++ tests/components/asuswrt/conftest.py | 83 +++++ tests/components/asuswrt/test_config_flow.py | 332 ++++++++----------- tests/components/asuswrt/test_diagnostics.py | 41 +++ tests/components/asuswrt/test_init.py | 30 ++ tests/components/asuswrt/test_sensor.py | 254 ++++---------- 6 files changed, 424 insertions(+), 369 deletions(-) create mode 100644 tests/components/asuswrt/common.py create mode 100644 tests/components/asuswrt/conftest.py create mode 100644 tests/components/asuswrt/test_diagnostics.py create mode 100644 tests/components/asuswrt/test_init.py diff --git a/tests/components/asuswrt/common.py b/tests/components/asuswrt/common.py new file mode 100644 index 00000000000..8572584d65f --- /dev/null +++ b/tests/components/asuswrt/common.py @@ -0,0 +1,53 @@ +"""Test code shared between test files.""" + +from aioasuswrt.asuswrt import Device as LegacyDevice + +from homeassistant.components.asuswrt.const import ( + CONF_SSH_KEY, + MODE_ROUTER, + PROTOCOL_SSH, + PROTOCOL_TELNET, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) + +ASUSWRT_BASE = "homeassistant.components.asuswrt" + +HOST = "myrouter.asuswrt.com" +ROUTER_MAC_ADDR = "a1:b2:c3:d4:e5:f6" + +CONFIG_DATA_TELNET = { + CONF_HOST: HOST, + CONF_PORT: 23, + CONF_PROTOCOL: PROTOCOL_TELNET, + CONF_USERNAME: "user", + CONF_PASSWORD: "pwd", + CONF_MODE: MODE_ROUTER, +} + +CONFIG_DATA_SSH = { + CONF_HOST: HOST, + CONF_PORT: 22, + CONF_PROTOCOL: PROTOCOL_SSH, + CONF_USERNAME: "user", + CONF_SSH_KEY: "aaaaa", + CONF_MODE: MODE_ROUTER, +} + +MOCK_MACS = [ + "A1:B1:C1:D1:E1:F1", + "A2:B2:C2:D2:E2:F2", + "A3:B3:C3:D3:E3:F3", + "A4:B4:C4:D4:E4:F4", +] + + +def new_device(mac, ip, name): + """Return a new device for specific protocol.""" + return LegacyDevice(mac, ip, name) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py new file mode 100644 index 00000000000..7596e94549d --- /dev/null +++ b/tests/components/asuswrt/conftest.py @@ -0,0 +1,83 @@ +"""Fixtures for Asuswrt component.""" + +from unittest.mock import Mock, patch + +from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy +from aioasuswrt.connection import TelnetConnection +import pytest + +from .common import ASUSWRT_BASE, MOCK_MACS, ROUTER_MAC_ADDR, new_device + +ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" + +MOCK_BYTES_TOTAL = [60000000000, 50000000000] +MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] +MOCK_LOAD_AVG = [1.1, 1.2, 1.3] +MOCK_TEMPERATURES = {"2.4GHz": 40.2, "CPU": 71.2} + + +@pytest.fixture(name="patch_setup_entry") +def mock_controller_patch_setup_entry(): + """Mock setting up a config entry.""" + with patch( + f"{ASUSWRT_BASE}.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + +@pytest.fixture(name="mock_devices_legacy") +def mock_devices_legacy_fixture(): + """Mock a list of devices.""" + return { + MOCK_MACS[0]: new_device(MOCK_MACS[0], "192.168.1.2", "Test"), + MOCK_MACS[1]: new_device(MOCK_MACS[1], "192.168.1.3", "TestTwo"), + } + + +@pytest.fixture(name="mock_available_temps") +def mock_available_temps_fixture(): + """Mock a list of available temperature sensors.""" + return [True, False, True] + + +@pytest.fixture(name="connect_legacy") +def mock_controller_connect_legacy(mock_devices_legacy, mock_available_temps): + """Mock a successful connection with legacy library.""" + with patch(ASUSWRT_LEGACY_LIB, spec=AsusWrtLegacy) as service_mock: + service_mock.return_value.connection = Mock(spec=TelnetConnection) + service_mock.return_value.is_connected = True + service_mock.return_value.async_get_nvram.return_value = { + "label_mac": ROUTER_MAC_ADDR, + "model": "abcd", + "firmver": "efg", + "buildno": "123", + } + service_mock.return_value.async_get_connected_devices.return_value = ( + mock_devices_legacy + ) + service_mock.return_value.async_get_bytes_total.return_value = MOCK_BYTES_TOTAL + service_mock.return_value.async_get_current_transfer_rates.return_value = ( + MOCK_CURRENT_TRANSFER_RATES + ) + service_mock.return_value.async_get_loadavg.return_value = MOCK_LOAD_AVG + service_mock.return_value.async_get_temperature.return_value = MOCK_TEMPERATURES + service_mock.return_value.async_find_temperature_commands.return_value = ( + mock_available_temps + ) + yield service_mock + + +@pytest.fixture(name="connect_legacy_sens_fail") +def mock_controller_connect_legacy_sens_fail(connect_legacy): + """Mock a successful connection using legacy library with sensors fail.""" + connect_legacy.return_value.async_get_nvram.side_effect = OSError + connect_legacy.return_value.async_get_connected_devices.side_effect = OSError + connect_legacy.return_value.async_get_bytes_total.side_effect = OSError + connect_legacy.return_value.async_get_current_transfer_rates.side_effect = OSError + connect_legacy.return_value.async_get_loadavg.side_effect = OSError + connect_legacy.return_value.async_get_temperature.side_effect = OSError + connect_legacy.return_value.async_find_temperature_commands.return_value = [ + True, + True, + True, + ] diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index bdee4f82f90..ec81c4a256a 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the AsusWrt config flow.""" from socket import gaierror -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch import pytest @@ -12,105 +12,75 @@ from homeassistant.components.asuswrt.const import ( CONF_SSH_KEY, CONF_TRACK_UNKNOWN, DOMAIN, - PROTOCOL_TELNET, + MODE_AP, ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import ( - CONF_HOST, - CONF_MODE, - CONF_PASSWORD, - CONF_PORT, - CONF_PROTOCOL, - CONF_USERNAME, -) +from homeassistant.const import CONF_HOST, CONF_MODE, CONF_PASSWORD from homeassistant.core import HomeAssistant +from .common import ASUSWRT_BASE, CONFIG_DATA_TELNET, HOST, ROUTER_MAC_ADDR + from tests.common import MockConfigEntry -HOST = "myrouter.asuswrt.com" -IP_ADDRESS = "192.168.1.1" -MAC_ADDR = "a1:b1:c1:d1:e1:f1" SSH_KEY = "1234" -CONFIG_DATA = { - CONF_HOST: HOST, - CONF_PORT: 23, - CONF_PROTOCOL: PROTOCOL_TELNET, - CONF_USERNAME: "user", - CONF_PASSWORD: "pwd", - CONF_MODE: "ap", -} -PATCH_GET_HOST = patch( - "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", - return_value=IP_ADDRESS, -) - -PATCH_SETUP_ENTRY = patch( - "homeassistant.components.asuswrt.async_setup_entry", - return_value=True, -) +@pytest.fixture(name="patch_get_host", autouse=True) +def mock_controller_patch_get_host(): + """Mock call to socket gethostbyname function.""" + with patch( + f"{ASUSWRT_BASE}.config_flow.socket.gethostbyname", return_value="192.168.1.1" + ) as get_host_mock: + yield get_host_mock -@pytest.fixture(name="mock_unique_id") -def mock_unique_id_fixture(): - """Mock returned unique id.""" - return {} +@pytest.fixture(name="patch_is_file", autouse=True) +def mock_controller_patch_is_file(): + """Mock call to os path.isfile function.""" + with patch( + f"{ASUSWRT_BASE}.config_flow.os.path.isfile", return_value=True + ) as is_file_mock: + yield is_file_mock -@pytest.fixture(name="connect") -def mock_controller_connect(mock_unique_id): - """Mock a successful connection.""" - with patch("homeassistant.components.asuswrt.bridge.AsusWrtLegacy") as service_mock: - service_mock.return_value.connection.async_connect = AsyncMock() - service_mock.return_value.is_connected = True - service_mock.return_value.connection.disconnect = Mock() - service_mock.return_value.async_get_nvram = AsyncMock( - return_value=mock_unique_id - ) - yield service_mock - - -@pytest.mark.usefixtures("connect") -@pytest.mark.parametrize( - "unique_id", - [{}, {"label_mac": MAC_ADDR}], -) -async def test_user(hass: HomeAssistant, mock_unique_id, unique_id) -> None: +@pytest.mark.parametrize("unique_id", [{}, {"label_mac": ROUTER_MAC_ADDR}]) +async def test_user( + hass: HomeAssistant, connect_legacy, patch_setup_entry, unique_id +) -> None: """Test user config.""" - mock_unique_id.update(unique_id) flow_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True} ) assert flow_result["type"] == data_entry_flow.FlowResultType.FORM assert flow_result["step_id"] == "user" + connect_legacy.return_value.async_get_nvram.return_value = unique_id + # test with all provided - with PATCH_GET_HOST, PATCH_SETUP_ENTRY as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - flow_result["flow_id"], - user_input=CONFIG_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], + user_input=CONFIG_DATA_TELNET, + ) + await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"] == CONFIG_DATA + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == CONFIG_DATA_TELNET - assert len(mock_setup_entry.mock_calls) == 1 + assert len(patch_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( ("config", "error"), [ - ({CONF_PASSWORD: None}, "pwd_or_ssh"), - ({CONF_SSH_KEY: SSH_KEY}, "pwd_and_ssh"), + ({}, "pwd_or_ssh"), + ({CONF_PASSWORD: "pwd", CONF_SSH_KEY: SSH_KEY}, "pwd_and_ssh"), ], ) async def test_error_wrong_password_ssh(hass: HomeAssistant, config, error) -> None: """Test we abort for wrong password and ssh file combination.""" - config_data = CONFIG_DATA.copy() + config_data = {k: v for k, v in CONFIG_DATA_TELNET.items() if k != CONF_PASSWORD} config_data.update(config) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -122,102 +92,94 @@ async def test_error_wrong_password_ssh(hass: HomeAssistant, config, error) -> N assert result["errors"] == {"base": error} -async def test_error_invalid_ssh(hass: HomeAssistant) -> None: +async def test_error_invalid_ssh(hass: HomeAssistant, patch_is_file) -> None: """Test we abort if invalid ssh file is provided.""" - config_data = CONFIG_DATA.copy() - config_data.pop(CONF_PASSWORD) + config_data = {k: v for k, v in CONFIG_DATA_TELNET.items() if k != CONF_PASSWORD} config_data[CONF_SSH_KEY] = SSH_KEY - with patch( - "homeassistant.components.asuswrt.config_flow.os.path.isfile", - return_value=False, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER, "show_advanced_options": True}, - data=config_data, - ) + patch_is_file.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=config_data, + ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": "ssh_not_file"} + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "ssh_not_file"} -async def test_error_invalid_host(hass: HomeAssistant) -> None: +async def test_error_invalid_host(hass: HomeAssistant, patch_get_host) -> None: """Test we abort if host name is invalid.""" - with patch( - "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", - side_effect=gaierror, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA, - ) + patch_get_host.side_effect = gaierror + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_DATA_TELNET, + ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": "invalid_host"} + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "invalid_host"} async def test_abort_if_not_unique_id_setup(hass: HomeAssistant) -> None: """Test we abort if component without uniqueid is already setup.""" MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, + data=CONFIG_DATA_TELNET, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data=CONFIG_DATA, + data=CONFIG_DATA_TELNET, ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "no_unique_id" -@pytest.mark.usefixtures("connect") -async def test_update_uniqueid_exist(hass: HomeAssistant, mock_unique_id) -> None: +async def test_update_uniqueid_exist( + hass: HomeAssistant, connect_legacy, patch_setup_entry +) -> None: """Test we update entry if uniqueid is already configured.""" - mock_unique_id.update({"label_mac": MAC_ADDR}) existing_entry = MockConfigEntry( domain=DOMAIN, - data={**CONFIG_DATA, CONF_HOST: "10.10.10.10"}, - unique_id=MAC_ADDR, + data={**CONFIG_DATA_TELNET, CONF_HOST: "10.10.10.10"}, + unique_id=ROUTER_MAC_ADDR, ) existing_entry.add_to_hass(hass) # test with all provided - with PATCH_GET_HOST, PATCH_SETUP_ENTRY: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER, "show_advanced_options": True}, - data=CONFIG_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=CONFIG_DATA_TELNET, + ) + await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"] == CONFIG_DATA - prev_entry = hass.config_entries.async_get_entry(existing_entry.entry_id) - assert not prev_entry + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == CONFIG_DATA_TELNET + prev_entry = hass.config_entries.async_get_entry(existing_entry.entry_id) + assert not prev_entry -@pytest.mark.usefixtures("connect") -async def test_abort_invalid_unique_id(hass: HomeAssistant) -> None: +async def test_abort_invalid_unique_id(hass: HomeAssistant, connect_legacy) -> None: """Test we abort if uniqueid not available.""" MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, - unique_id=MAC_ADDR, + data=CONFIG_DATA_TELNET, + unique_id=ROUTER_MAC_ADDR, ).add_to_hass(hass) - with PATCH_GET_HOST: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA, - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "invalid_unique_id" + connect_legacy.return_value.async_get_nvram.return_value = {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_DATA_TELNET, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "invalid_unique_id" @pytest.mark.parametrize( @@ -228,95 +190,93 @@ async def test_abort_invalid_unique_id(hass: HomeAssistant) -> None: (None, "cannot_connect"), ], ) -async def test_on_connect_failed(hass: HomeAssistant, side_effect, error) -> None: +async def test_on_connect_failed( + hass: HomeAssistant, connect_legacy, side_effect, error +) -> None: """Test when we have errors connecting the router.""" flow_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, ) - with PATCH_GET_HOST, patch( - "homeassistant.components.asuswrt.bridge.AsusWrtLegacy" - ) as asus_wrt: - asus_wrt.return_value.connection.async_connect = AsyncMock( - side_effect=side_effect - ) - asus_wrt.return_value.async_get_nvram = AsyncMock(return_value={}) - asus_wrt.return_value.is_connected = False + connect_legacy.return_value.is_connected = False + connect_legacy.return_value.connection.async_connect.side_effect = side_effect - result = await hass.config_entries.flow.async_configure( - flow_result["flow_id"], user_input=CONFIG_DATA - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": error} + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA_TELNET + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": error} -async def test_options_flow_ap(hass: HomeAssistant) -> None: +async def test_options_flow_ap(hass: HomeAssistant, patch_setup_entry) -> None: """Test config flow options for ap mode.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, + data={**CONFIG_DATA_TELNET, CONF_MODE: MODE_AP}, options={CONF_REQUIRE_IP: True}, ) config_entry.add_to_hass(hass) - with PATCH_SETUP_ENTRY: - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert CONF_REQUIRE_IP in result["data_schema"].schema + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert CONF_REQUIRE_IP in result["data_schema"].schema - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_CONSIDER_HOME: 20, - CONF_TRACK_UNKNOWN: True, - CONF_INTERFACE: "aaa", - CONF_DNSMASQ: "bbb", - CONF_REQUIRE_IP: False, - }, - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + CONF_REQUIRE_IP: False, + }, + ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_CONSIDER_HOME] == 20 - assert config_entry.options[CONF_TRACK_UNKNOWN] is True - assert config_entry.options[CONF_INTERFACE] == "aaa" - assert config_entry.options[CONF_DNSMASQ] == "bbb" - assert config_entry.options[CONF_REQUIRE_IP] is False + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + CONF_REQUIRE_IP: False, + } -async def test_options_flow_router(hass: HomeAssistant) -> None: +async def test_options_flow_router(hass: HomeAssistant, patch_setup_entry) -> None: """Test config flow options for router mode.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={**CONFIG_DATA, CONF_MODE: "router"}, + data=CONFIG_DATA_TELNET, ) config_entry.add_to_hass(hass) - with PATCH_SETUP_ENTRY: - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert CONF_REQUIRE_IP not in result["data_schema"].schema + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert CONF_REQUIRE_IP not in result["data_schema"].schema - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_CONSIDER_HOME: 20, - CONF_TRACK_UNKNOWN: True, - CONF_INTERFACE: "aaa", - CONF_DNSMASQ: "bbb", - }, - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + }, + ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_CONSIDER_HOME] == 20 - assert config_entry.options[CONF_TRACK_UNKNOWN] is True - assert config_entry.options[CONF_INTERFACE] == "aaa" - assert config_entry.options[CONF_DNSMASQ] == "bbb" + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + } diff --git a/tests/components/asuswrt/test_diagnostics.py b/tests/components/asuswrt/test_diagnostics.py new file mode 100644 index 00000000000..1c09dd29adc --- /dev/null +++ b/tests/components/asuswrt/test_diagnostics.py @@ -0,0 +1,41 @@ +"""Tests for the diagnostics data provided by the AsusWRT integration.""" + +from homeassistant.components.asuswrt.const import DOMAIN +from homeassistant.components.asuswrt.diagnostics import TO_REDACT +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import CONFIG_DATA_TELNET, ROUTER_MAC_ADDR + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + connect_legacy, +) -> None: + """Test diagnostics.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA_TELNET, + options={CONF_CONSIDER_HOME: 60}, + unique_id=ROUTER_MAC_ADDR, + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == ConfigEntryState.LOADED + + entry_dict = async_redact_data(mock_config_entry.as_dict(), TO_REDACT) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result["entry"] == entry_dict diff --git a/tests/components/asuswrt/test_init.py b/tests/components/asuswrt/test_init.py new file mode 100644 index 00000000000..72897b737e5 --- /dev/null +++ b/tests/components/asuswrt/test_init.py @@ -0,0 +1,30 @@ +"""Tests for the AsusWrt integration.""" + +from homeassistant.components.asuswrt.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from .common import CONFIG_DATA_TELNET, ROUTER_MAC_ADDR + +from tests.common import MockConfigEntry + + +async def test_disconnect_on_stop(hass: HomeAssistant, connect_legacy) -> None: + """Test we close the connection with the router when Home Assistants stops.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA_TELNET, + unique_id=ROUTER_MAC_ADDR, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert connect_legacy.return_value.connection.disconnect.call_count == 1 + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 52525390666..92f40dd8511 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -1,16 +1,12 @@ """Tests for the AsusWrt sensor.""" from datetime import timedelta -from unittest.mock import AsyncMock, Mock, patch -from aioasuswrt.asuswrt import Device import pytest from homeassistant.components import device_tracker, sensor from homeassistant.components.asuswrt.const import ( CONF_INTERFACE, DOMAIN, - MODE_ROUTER, - PROTOCOL_TELNET, SENSORS_BYTES, SENSORS_LOAD_AVG, SENSORS_RATES, @@ -18,76 +14,19 @@ from homeassistant.components.asuswrt.const import ( ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_HOST, - CONF_MODE, - CONF_PASSWORD, - CONF_PORT, - CONF_PROTOCOL, - CONF_USERNAME, - STATE_HOME, - STATE_NOT_HOME, - STATE_UNAVAILABLE, -) +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import slugify from homeassistant.util.dt import utcnow +from .common import CONFIG_DATA_TELNET, HOST, MOCK_MACS, ROUTER_MAC_ADDR, new_device + from tests.common import MockConfigEntry, async_fire_time_changed -ASUSWRT_LIB = "homeassistant.components.asuswrt.bridge.AsusWrtLegacy" - -HOST = "myrouter.asuswrt.com" -IP_ADDRESS = "192.168.1.1" - -CONFIG_DATA = { - CONF_HOST: HOST, - CONF_PORT: 22, - CONF_PROTOCOL: PROTOCOL_TELNET, - CONF_USERNAME: "user", - CONF_PASSWORD: "pwd", - CONF_MODE: MODE_ROUTER, -} - -MAC_ADDR = "a1:b2:c3:d4:e5:f6" - -MOCK_BYTES_TOTAL = [60000000000, 50000000000] -MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] -MOCK_LOAD_AVG = [1.1, 1.2, 1.3] -MOCK_TEMPERATURES = {"2.4GHz": 40.0, "5.0GHz": 0.0, "CPU": 71.2} -MOCK_MAC_1 = "A1:B1:C1:D1:E1:F1" -MOCK_MAC_2 = "A2:B2:C2:D2:E2:F2" -MOCK_MAC_3 = "A3:B3:C3:D3:E3:F3" -MOCK_MAC_4 = "A4:B4:C4:D4:E4:F4" - SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] -SENSORS_ALL = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] -PATCH_SETUP_ENTRY = patch( - "homeassistant.components.asuswrt.async_setup_entry", - return_value=True, -) - - -def new_device(mac, ip, name): - """Return a new device for specific protocol.""" - return Device(mac, ip, name) - - -@pytest.fixture(name="mock_devices") -def mock_devices_fixture(): - """Mock a list of devices.""" - return { - MOCK_MAC_1: Device(MOCK_MAC_1, "192.168.1.2", "Test"), - MOCK_MAC_2: Device(MOCK_MAC_2, "192.168.1.3", "TestTwo"), - } - - -@pytest.fixture(name="mock_available_temps") -def mock_available_temps_fixture(): - """Mock a list of available temperature sensors.""" - return [True, False, True] +SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] @pytest.fixture(name="create_device_registry_devices") @@ -97,12 +36,7 @@ def create_device_registry_devices_fixture(hass: HomeAssistant): config_entry = MockConfigEntry(domain="something_else") config_entry.add_to_hass(hass) - for idx, device in enumerate( - ( - MOCK_MAC_3, - MOCK_MAC_4, - ) - ): + for idx, device in enumerate((MOCK_MACS[2], MOCK_MACS[3])): dev_reg.async_get_or_create( name=f"Device {idx}", config_entry_id=config_entry.entry_id, @@ -110,65 +44,6 @@ def create_device_registry_devices_fixture(hass: HomeAssistant): ) -@pytest.fixture(name="connect") -def mock_controller_connect(mock_devices, mock_available_temps): - """Mock a successful connection with AsusWrt library.""" - with patch(ASUSWRT_LIB) as service_mock: - service_mock.return_value.connection.async_connect = AsyncMock() - service_mock.return_value.is_connected = True - service_mock.return_value.connection.disconnect = Mock() - service_mock.return_value.async_get_nvram = AsyncMock( - return_value={ - "label_mac": MAC_ADDR, - "model": "abcd", - "firmver": "efg", - "buildno": "123", - } - ) - service_mock.return_value.async_get_connected_devices = AsyncMock( - return_value=mock_devices - ) - service_mock.return_value.async_get_bytes_total = AsyncMock( - return_value=MOCK_BYTES_TOTAL - ) - service_mock.return_value.async_get_current_transfer_rates = AsyncMock( - return_value=MOCK_CURRENT_TRANSFER_RATES - ) - service_mock.return_value.async_get_loadavg = AsyncMock( - return_value=MOCK_LOAD_AVG - ) - service_mock.return_value.async_get_temperature = AsyncMock( - return_value=MOCK_TEMPERATURES - ) - service_mock.return_value.async_find_temperature_commands = AsyncMock( - return_value=mock_available_temps - ) - yield service_mock - - -@pytest.fixture(name="connect_sens_fail") -def mock_controller_connect_sens_fail(): - """Mock a successful connection using AsusWrt library with sensors failing.""" - with patch(ASUSWRT_LIB) as service_mock: - service_mock.return_value.connection.async_connect = AsyncMock() - service_mock.return_value.is_connected = True - service_mock.return_value.connection.disconnect = Mock() - service_mock.return_value.async_get_nvram = AsyncMock(side_effect=OSError) - service_mock.return_value.async_get_connected_devices = AsyncMock( - side_effect=OSError - ) - service_mock.return_value.async_get_bytes_total = AsyncMock(side_effect=OSError) - service_mock.return_value.async_get_current_transfer_rates = AsyncMock( - side_effect=OSError - ) - service_mock.return_value.async_get_loadavg = AsyncMock(side_effect=OSError) - service_mock.return_value.async_get_temperature = AsyncMock(side_effect=OSError) - service_mock.return_value.async_find_temperature_commands = AsyncMock( - return_value=[True, True, True] - ) - yield service_mock - - def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): """Create mock config entry with enabled sensors.""" entity_reg = er.async_get(hass) @@ -201,28 +76,23 @@ def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): return config_entry, sensor_prefix -@pytest.mark.parametrize( - "entry_unique_id", - [None, MAC_ADDR], -) -async def test_sensors( +async def _test_sensors( hass: HomeAssistant, - connect, mock_devices, - create_device_registry_devices, + config, entry_unique_id, ) -> None: """Test creating AsusWRT default sensors and tracker.""" config_entry, sensor_prefix = _setup_entry( - hass, CONFIG_DATA, SENSORS_DEFAULT, entry_unique_id + hass, config, SENSORS_DEFAULT, entry_unique_id ) # Create the first device tracker to test mac conversion entity_reg = er.async_get(hass) for mac, name in { - MOCK_MAC_1: "test", - dr.format_mac(MOCK_MAC_2): "testtwo", - MOCK_MAC_2: "testremove", + MOCK_MACS[0]: "test", + dr.format_mac(MOCK_MACS[1]): "testtwo", + MOCK_MACS[1]: "testremove", }.items(): entity_reg.async_get_or_create( device_tracker.DOMAIN, @@ -250,7 +120,7 @@ async def test_sensors( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "2" # remove first tracked device - mock_devices.pop(MOCK_MAC_1) + mock_devices.pop(MOCK_MACS[0]) async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -261,8 +131,8 @@ async def test_sensors( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "1" # add 2 new devices, one unnamed that should be ignored but counted - mock_devices[MOCK_MAC_3] = new_device(MOCK_MAC_3, "192.168.1.4", "TestThree") - mock_devices[MOCK_MAC_4] = new_device(MOCK_MAC_4, "192.168.1.5", None) + mock_devices[MOCK_MACS[2]] = new_device(MOCK_MACS[2], "192.168.1.4", "TestThree") + mock_devices[MOCK_MACS[3]] = new_device(MOCK_MACS[3], "192.168.1.5", None) # change consider home settings to have status not home of removed tracked device hass.config_entries.async_update_entry( @@ -279,12 +149,26 @@ async def test_sensors( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "3" -async def test_loadavg_sensors( +@pytest.mark.parametrize( + "entry_unique_id", + [None, ROUTER_MAC_ADDR], +) +async def test_sensors( hass: HomeAssistant, - connect, + connect_legacy, + mock_devices_legacy, + create_device_registry_devices, + entry_unique_id, ) -> None: + """Test creating AsusWRT default sensors and tracker with legacy protocol.""" + await _test_sensors(hass, mock_devices_legacy, CONFIG_DATA_TELNET, entry_unique_id) + + +async def test_loadavg_sensors(hass: HomeAssistant, connect_legacy) -> None: """Test creating an AsusWRT load average sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_LOAD_AVG) + config_entry, sensor_prefix = _setup_entry( + hass, CONFIG_DATA_TELNET, SENSORS_LOAD_AVG + ) config_entry.add_to_hass(hass) # initial devices setup @@ -299,12 +183,11 @@ async def test_loadavg_sensors( assert hass.states.get(f"{sensor_prefix}_sensor_load_avg15").state == "1.3" -async def test_temperature_sensors( - hass: HomeAssistant, - connect, -) -> None: +async def test_temperature_sensors(hass: HomeAssistant, connect_legacy) -> None: """Test creating a AsusWRT temperature sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_TEMPERATURES) + config_entry, sensor_prefix = _setup_entry( + hass, CONFIG_DATA_TELNET, SENSORS_TEMPERATURES + ) config_entry.add_to_hass(hass) # initial devices setup @@ -314,7 +197,7 @@ async def test_temperature_sensors( await hass.async_block_till_done() # assert temperature sensor available - assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.0" + assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" assert not hass.states.get(f"{sensor_prefix}_5_0ghz") assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" @@ -323,32 +206,32 @@ async def test_temperature_sensors( "side_effect", [OSError, None], ) -async def test_connect_fail(hass: HomeAssistant, side_effect) -> None: +async def test_connect_fail(hass: HomeAssistant, connect_legacy, side_effect) -> None: """Test AsusWRT connect fail.""" # init config entry config_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, + data=CONFIG_DATA_TELNET, ) config_entry.add_to_hass(hass) - with patch(ASUSWRT_LIB) as asus_wrt: - asus_wrt.return_value.connection.async_connect = AsyncMock( - side_effect=side_effect - ) - asus_wrt.return_value.async_get_nvram = AsyncMock() - asus_wrt.return_value.is_connected = False + connect_legacy.return_value.connection.async_connect.side_effect = side_effect + connect_legacy.return_value.is_connected = False - # initial setup fail - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + # initial setup fail + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_sensors_polling_fails(hass: HomeAssistant, connect_sens_fail) -> None: +async def test_sensors_polling_fails( + hass: HomeAssistant, connect_legacy_sens_fail +) -> None: """Test AsusWRT sensors are unavailable when polling fails.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_ALL) + config_entry, sensor_prefix = _setup_entry( + hass, CONFIG_DATA_TELNET, SENSORS_ALL_LEGACY + ) config_entry.add_to_hass(hass) # initial devices setup @@ -357,7 +240,7 @@ async def test_sensors_polling_fails(hass: HomeAssistant, connect_sens_fail) -> async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - for sensor_name in SENSORS_ALL: + for sensor_name in SENSORS_ALL_LEGACY: assert ( hass.states.get(f"{sensor_prefix}_{slugify(sensor_name)}").state == STATE_UNAVAILABLE @@ -365,33 +248,38 @@ async def test_sensors_polling_fails(hass: HomeAssistant, connect_sens_fail) -> assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "0" -async def test_options_reload(hass: HomeAssistant, connect) -> None: +async def test_options_reload(hass: HomeAssistant, connect_legacy) -> None: """Test AsusWRT integration is reload changing an options that require this.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA, unique_id=MAC_ADDR) + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA_TELNET, + unique_id=ROUTER_MAC_ADDR, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert connect_legacy.return_value.connection.async_connect.call_count == 1 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - with PATCH_SETUP_ENTRY as setup_entry_call: - # change an option that requires integration reload - hass.config_entries.async_update_entry( - config_entry, options={CONF_INTERFACE: "eth1"} - ) - await hass.async_block_till_done() + # change an option that requires integration reload + hass.config_entries.async_update_entry( + config_entry, options={CONF_INTERFACE: "eth1"} + ) + await hass.async_block_till_done() - assert setup_entry_call.called - assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED + assert connect_legacy.return_value.connection.async_connect.call_count == 2 -async def test_unique_id_migration(hass: HomeAssistant, connect) -> None: +async def test_unique_id_migration(hass: HomeAssistant, connect_legacy) -> None: """Test AsusWRT entities unique id format migration.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, - unique_id=MAC_ADDR, + data=CONFIG_DATA_TELNET, + unique_id=ROUTER_MAC_ADDR, ) config_entry.add_to_hass(hass) @@ -400,7 +288,7 @@ async def test_unique_id_migration(hass: HomeAssistant, connect) -> None: entity_reg.async_get_or_create( sensor.DOMAIN, DOMAIN, - f"{DOMAIN} {MAC_ADDR} Upload", + f"{DOMAIN} {ROUTER_MAC_ADDR} Upload", suggested_object_id=obj_entity_id, config_entry=config_entry, disabled_by=None, @@ -411,4 +299,4 @@ async def test_unique_id_migration(hass: HomeAssistant, connect) -> None: migr_entity = entity_reg.async_get(f"{sensor.DOMAIN}.{obj_entity_id}") assert migr_entity is not None - assert migr_entity.unique_id == slugify(f"{MAC_ADDR}_sensor_tx_bytes") + assert migr_entity.unique_id == slugify(f"{ROUTER_MAC_ADDR}_sensor_tx_bytes") From cd94ad125abf70f1325d0a84e01ff99afce45f4d Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 9 Nov 2023 09:41:05 +0000 Subject: [PATCH 354/982] Bump pytrydan to 0.3.0 (#103691) --- homeassistant/components/v2c/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ce81f3e1424..d1a65e5f63d 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/v2c", "iot_class": "local_polling", - "requirements": ["pytrydan==0.1.2"] + "requirements": ["pytrydan==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14f8fa41b7a..cc6be993d08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2226,7 +2226,7 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.8 # homeassistant.components.v2c -pytrydan==0.1.2 +pytrydan==0.3.0 # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f916f35beb2..0fbde65a23f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1661,7 +1661,7 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.8 # homeassistant.components.v2c -pytrydan==0.1.2 +pytrydan==0.3.0 # homeassistant.components.usb pyudev==0.23.2 From 4bbdf475b4645e97257b5d0925d003a697794564 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 9 Nov 2023 10:12:05 +0000 Subject: [PATCH 355/982] Add switch platform to V2C (#103678) Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + homeassistant/components/v2c/__init__.py | 2 +- homeassistant/components/v2c/coordinator.py | 2 +- homeassistant/components/v2c/strings.json | 5 ++ homeassistant/components/v2c/switch.py | 93 +++++++++++++++++++++ 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/v2c/switch.py diff --git a/.coveragerc b/.coveragerc index d58eafa442c..0a7e8cd7489 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1433,6 +1433,7 @@ omit = homeassistant/components/v2c/coordinator.py homeassistant/components/v2c/entity.py homeassistant/components/v2c/sensor.py + homeassistant/components/v2c/switch.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py homeassistant/components/velbus/button.py diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 030ae56bb79..c1b22b5735d 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers.httpx_client import get_async_client from .const import DOMAIN from .coordinator import V2CUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py index b2db66f1b80..2ab6b967fc6 100644 --- a/homeassistant/components/v2c/coordinator.py +++ b/homeassistant/components/v2c/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -SCAN_INTERVAL = timedelta(seconds=60) +SCAN_INTERVAL = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index 7ef658b5daa..fb30ea826d7 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -32,6 +32,11 @@ "fv_power": { "name": "Photovoltaic power" } + }, + "switch": { + "paused": { + "name": "Pause session" + } } } } diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py new file mode 100644 index 00000000000..d54c14f88d6 --- /dev/null +++ b/homeassistant/components/v2c/switch.py @@ -0,0 +1,93 @@ +"""Switch platform for V2C EVSE.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from pytrydan import Trydan, TrydanData +from pytrydan.models.trydan import PauseState + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator +from .entity import V2CBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class V2CRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TrydanData], bool] + turn_on_fn: Callable[[Trydan], Coroutine[Any, Any, Any]] + turn_off_fn: Callable[[Trydan], Coroutine[Any, Any, Any]] + + +@dataclass +class V2CSwitchEntityDescription(SwitchEntityDescription, V2CRequiredKeysMixin): + """Describes a V2C EVSE switch entity.""" + + +TRYDAN_SWITCHES = ( + V2CSwitchEntityDescription( + key="paused", + translation_key="paused", + icon="mdi:pause", + value_fn=lambda evse_data: evse_data.paused == PauseState.PAUSED, + turn_on_fn=lambda evse: evse.pause(), + turn_off_fn=lambda evse: evse.resume(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C switch platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[SwitchEntity] = [ + V2CSwitchEntity(coordinator, description, config_entry.entry_id) + for description in TRYDAN_SWITCHES + ] + async_add_entities(entities) + + +class V2CSwitchEntity(V2CBaseEntity, SwitchEntity): + """Representation of a V2C switch entity.""" + + entity_description: V2CSwitchEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: SwitchEntityDescription, + entry_id: str, + ) -> None: + """Initialize the V2C switch entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the EVSE switch.""" + return self.entity_description.value_fn(self.data) + + async def async_turn_on(self): + """Turn on the EVSE switch.""" + await self.entity_description.turn_on_fn(self.coordinator.evse) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self): + """Turn off the EVSE switch.""" + await self.entity_description.turn_off_fn(self.coordinator.evse) + await self.coordinator.async_request_refresh() From 9af5e838c68000730b2781b39d87c5dcb1a0f504 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 9 Nov 2023 03:31:12 -0800 Subject: [PATCH 356/982] Add type annotation for service functions with response (#102813) Co-authored-by: Martin Hjelmare --- homeassistant/core.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index ab0fa3b6892..d174786d968 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1773,7 +1773,10 @@ class Service: self, func: Callable[ [ServiceCall], - Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] | None, + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] + | ServiceResponse + | EntityServiceResponse + | None, ], schema: vol.Schema | None, domain: str, @@ -1865,7 +1868,7 @@ class ServiceRegistry: service: str, service_func: Callable[ [ServiceCall], - Coroutine[Any, Any, ServiceResponse] | None, + Coroutine[Any, Any, ServiceResponse] | ServiceResponse | None, ], schema: vol.Schema | None = None, ) -> None: @@ -1884,7 +1887,10 @@ class ServiceRegistry: service: str, service_func: Callable[ [ServiceCall], - Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] | None, + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] + | ServiceResponse + | EntityServiceResponse + | None, ], schema: vol.Schema | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, From 1a6c3a49441ffd4897234d3fc8e18348fdadef17 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 9 Nov 2023 12:44:50 +0100 Subject: [PATCH 357/982] Add name to Withings coordinator (#103692) --- homeassistant/components/withings/coordinator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 7dec48a3489..2639ccccf7d 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -37,11 +37,15 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): _default_update_interval: timedelta | None = UPDATE_INTERVAL _last_valid_update: datetime | None = None webhooks_connected: bool = False + coordinator_name: str = "" def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__( - hass, LOGGER, name="Withings", update_interval=self._default_update_interval + hass, + LOGGER, + name=f"Withings {self.coordinator_name}", + update_interval=self._default_update_interval, ) self._client = client self.notification_categories: set[NotificationCategory] = set() @@ -77,6 +81,8 @@ class WithingsMeasurementDataUpdateCoordinator( ): """Withings measurement coordinator.""" + coordinator_name: str = "measurements" + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__(hass, client) @@ -109,6 +115,8 @@ class WithingsSleepDataUpdateCoordinator( ): """Withings sleep coordinator.""" + coordinator_name: str = "sleep" + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__(hass, client) @@ -156,6 +164,7 @@ class WithingsSleepDataUpdateCoordinator( class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): """Withings bed presence coordinator.""" + coordinator_name: str = "bed presence" in_bed: bool | None = None _default_update_interval = None @@ -181,6 +190,7 @@ class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[Non class WithingsGoalsDataUpdateCoordinator(WithingsDataUpdateCoordinator[Goals]): """Withings goals coordinator.""" + coordinator_name: str = "goals" _default_update_interval = timedelta(hours=1) def webhook_subscription_listener(self, connected: bool) -> None: @@ -197,6 +207,7 @@ class WithingsActivityDataUpdateCoordinator( ): """Withings activity coordinator.""" + coordinator_name: str = "activity" _previous_data: Activity | None = None def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: @@ -235,6 +246,7 @@ class WithingsWorkoutDataUpdateCoordinator( ): """Withings workout coordinator.""" + coordinator_name: str = "workout" _previous_data: Workout | None = None def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: From d1f1bbe304018e5feed5c11aa9add3472e3f2e19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Nov 2023 10:23:33 -0600 Subject: [PATCH 358/982] Migrate to using aiohttp-fast-url-dispatcher (#103656) --- homeassistant/components/http/__init__.py | 45 +-------------------- homeassistant/components/http/manifest.json | 6 ++- homeassistant/package_constraints.txt | 1 + pyproject.toml | 1 + requirements.txt | 1 + requirements_all.txt | 3 ++ requirements_test_all.txt | 3 ++ 7 files changed, 16 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 04a8c13bba2..5a1d182e80c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -18,11 +18,7 @@ from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_log import AccessLogger from aiohttp.web_protocol import RequestHandler -from aiohttp.web_urldispatcher import ( - AbstractResource, - UrlDispatcher, - UrlMappingMatchInfo, -) +from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher from aiohttp_zlib_ng import enable_zlib_ng from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization @@ -321,7 +317,7 @@ class HomeAssistantHTTP: # By default aiohttp does a linear search for routing rules, # we have a lot of routes, so use a dict lookup with a fallback # to the linear search. - self.app._router = FastUrlDispatcher() + attach_fast_url_dispatcher(self.app, FastUrlDispatcher()) self.hass = hass self.ssl_certificate = ssl_certificate self.ssl_peer_certificate = ssl_peer_certificate @@ -587,40 +583,3 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) - - -class FastUrlDispatcher(UrlDispatcher): - """UrlDispatcher that uses a dict lookup for resolving.""" - - def __init__(self) -> None: - """Initialize the dispatcher.""" - super().__init__() - self._resource_index: dict[str, list[AbstractResource]] = {} - - def register_resource(self, resource: AbstractResource) -> None: - """Register a resource.""" - super().register_resource(resource) - canonical = resource.canonical - if "{" in canonical: # strip at the first { to allow for variables - canonical = canonical.split("{")[0].rstrip("/") - # There may be multiple resources for a canonical path - # so we use a list to avoid falling back to a full linear search - self._resource_index.setdefault(canonical, []).append(resource) - - async def resolve(self, request: web.Request) -> UrlMappingMatchInfo: - """Resolve a request.""" - url_parts = request.rel_url.raw_parts - resource_index = self._resource_index - - # Walk the url parts looking for candidates - for i in range(len(url_parts), 0, -1): - url_part = "/" + "/".join(url_parts[1:i]) - if (resource_candidates := resource_index.get(url_part)) is not None: - for candidate in resource_candidates: - if ( - match_dict := (await candidate.resolve(request))[0] - ) is not None: - return match_dict - - # Finally, fallback to the linear search - return await super().resolve(request) diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index dffd1dd1d8c..f2f8b51665a 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -6,5 +6,9 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0", "aiohttp-zlib-ng==0.1.1"] + "requirements": [ + "aiohttp_cors==0.7.0", + "aiohttp-fast-url-dispatcher==0.1.0", + "aiohttp-zlib-ng==0.1.1" + ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37cb51d4178..03dd947e098 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,5 @@ aiodiscover==1.5.1 +aiohttp-fast-url-dispatcher==0.1.0 aiohttp-zlib-ng==0.1.1 aiohttp==3.8.5;python_version<'3.12' aiohttp==3.9.0b0;python_version>='3.12' diff --git a/pyproject.toml b/pyproject.toml index 4b079aed093..550cafc4146 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "aiohttp==3.9.0b0;python_version>='3.12'", "aiohttp==3.8.5;python_version<'3.12'", "aiohttp_cors==0.7.0", + "aiohttp-fast-url-dispatcher==0.1.0", "aiohttp-zlib-ng==0.1.1", "astral==2.2", "attrs==23.1.0", diff --git a/requirements.txt b/requirements.txt index 324217b0f55..1ca4643a747 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ aiohttp==3.9.0b0;python_version>='3.12' aiohttp==3.8.5;python_version<'3.12' aiohttp_cors==0.7.0 +aiohttp-fast-url-dispatcher==0.1.0 aiohttp-zlib-ng==0.1.1 astral==2.2 attrs==23.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index cc6be993d08..72fe9df3370 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,6 +257,9 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.0.9 +# homeassistant.components.http +aiohttp-fast-url-dispatcher==0.1.0 + # homeassistant.components.http aiohttp-zlib-ng==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fbde65a23f..34a82175d01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,6 +235,9 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.0.9 +# homeassistant.components.http +aiohttp-fast-url-dispatcher==0.1.0 + # homeassistant.components.http aiohttp-zlib-ng==0.1.1 From 81909f7ddffff55ef60d62570074fe0940706b87 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Nov 2023 18:06:53 +0100 Subject: [PATCH 359/982] Update deconz tests to use device & entity registry fixtures (#103703) --- tests/components/deconz/test_binary_sensor.py | 22 +++++++----- tests/components/deconz/test_button.py | 14 ++++---- tests/components/deconz/test_deconz_event.py | 34 ++++++++++-------- .../components/deconz/test_device_trigger.py | 36 +++++++++++-------- tests/components/deconz/test_gateway.py | 10 +++--- tests/components/deconz/test_init.py | 31 ++++++++++------ tests/components/deconz/test_logbook.py | 12 +++---- tests/components/deconz/test_number.py | 10 +++--- tests/components/deconz/test_scene.py | 14 ++++---- tests/components/deconz/test_select.py | 14 ++++---- tests/components/deconz/test_sensor.py | 16 +++++---- tests/components/deconz/test_services.py | 7 ++-- tests/components/deconz/test_switch.py | 11 +++--- 13 files changed, 134 insertions(+), 97 deletions(-) diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index e9556fe4b5d..68396c8ff9c 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -480,17 +480,17 @@ TEST_DATA = [ @pytest.mark.parametrize(("sensor_data", "expected"), TEST_DATA) async def test_binary_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket, sensor_data, expected, ) -> None: """Test successful creation of binary sensor entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) # Create entity entry to migrate to new unique ID - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( DOMAIN, DECONZ_DOMAIN, expected["old_unique_id"], @@ -513,14 +513,14 @@ async def test_binary_sensors( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) @@ -670,7 +670,10 @@ async def test_add_new_binary_sensor( async def test_add_new_binary_sensor_ignored_load_entities_on_service_call( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test that adding a new binary sensor is not allowed.""" sensor = { @@ -702,7 +705,6 @@ async def test_add_new_binary_sensor_ignored_load_entities_on_service_call( assert len(hass.states.async_all()) == 0 assert not hass.states.get("binary_sensor.presence_sensor") - entity_registry = er.async_get(hass) assert ( len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 0 ) @@ -719,7 +721,10 @@ async def test_add_new_binary_sensor_ignored_load_entities_on_service_call( async def test_add_new_binary_sensor_ignored_load_entities_on_options_change( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test that adding a new binary sensor is not allowed.""" sensor = { @@ -751,7 +756,6 @@ async def test_add_new_binary_sensor_ignored_load_entities_on_options_change( assert len(hass.states.async_all()) == 0 assert not hass.states.get("binary_sensor.presence_sensor") - entity_registry = er.async_get(hass) assert ( len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 0 ) diff --git a/tests/components/deconz/test_button.py b/tests/components/deconz/test_button.py index 87e80374e11..7f4dd59bf16 100644 --- a/tests/components/deconz/test_button.py +++ b/tests/components/deconz/test_button.py @@ -101,12 +101,14 @@ TEST_DATA = [ @pytest.mark.parametrize(("raw_data", "expected"), TEST_DATA) async def test_button( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, raw_data, expected + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + raw_data, + expected, ) -> None: """Test successful creation of button entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - with patch.dict(DECONZ_WEB_REQUEST, raw_data): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -119,14 +121,14 @@ async def test_button( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index f32fec7e486..403feb07915 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -34,7 +34,10 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_deconz_events( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test successful creation of deconz events.""" data = { @@ -79,8 +82,6 @@ async def test_deconz_events( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 3 # 5 switches + 2 additional devices for deconz service and host assert ( @@ -212,7 +213,10 @@ async def test_deconz_events( async def test_deconz_alarm_events( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test successful creation of deconz alarm events.""" data = { @@ -276,8 +280,6 @@ async def test_deconz_alarm_events( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 4 # 1 alarm control device + 2 additional devices for deconz service and host assert ( @@ -424,7 +426,10 @@ async def test_deconz_alarm_events( async def test_deconz_presence_events( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test successful creation of deconz presence events.""" data = { @@ -457,8 +462,6 @@ async def test_deconz_presence_events( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 5 assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) @@ -527,7 +530,10 @@ async def test_deconz_presence_events( async def test_deconz_relative_rotary_events( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test successful creation of deconz relative rotary events.""" data = { @@ -559,8 +565,6 @@ async def test_deconz_relative_rotary_events( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 1 assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) @@ -626,7 +630,9 @@ async def test_deconz_relative_rotary_events( async def test_deconz_events_bad_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Verify no devices are created if unique id is bad or missing.""" data = { @@ -649,8 +655,6 @@ async def test_deconz_events_bad_unique_id( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 1 assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index fe9d57f8a65..4c3344f5822 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -49,7 +49,10 @@ def automation_calls(hass): async def test_get_triggers( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test triggers work.""" data = { @@ -78,11 +81,9 @@ async def test_get_triggers( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) - entity_registry = er.async_get(hass) battery_sensor_entry = entity_registry.async_get( "sensor.tradfri_on_off_switch_battery" ) @@ -154,7 +155,10 @@ async def test_get_triggers( async def test_get_triggers_for_alarm_event( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test triggers work.""" data = { @@ -190,11 +194,9 @@ async def test_get_triggers_for_alarm_event( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:00")} ) - entity_registry = er.async_get(hass) bat_entity = entity_registry.async_get("sensor.keypad_battery") low_bat_entity = entity_registry.async_get("binary_sensor.keypad_low_battery") tamper_entity = entity_registry.async_get("binary_sensor.keypad_tampered") @@ -250,7 +252,9 @@ async def test_get_triggers_for_alarm_event( async def test_get_triggers_manage_unsupported_remotes( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Verify no triggers for an unsupported remote.""" data = { @@ -278,7 +282,6 @@ async def test_get_triggers_manage_unsupported_remotes( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) @@ -297,6 +300,7 @@ async def test_functional_device_trigger( aioclient_mock: AiohttpClientMocker, mock_deconz_websocket, automation_calls, + device_registry: dr.DeviceRegistry, ) -> None: """Test proper matching and attachment of device trigger automation.""" @@ -326,7 +330,6 @@ async def test_functional_device_trigger( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) @@ -403,12 +406,13 @@ async def test_validate_trigger_unknown_device( async def test_validate_trigger_unsupported_device( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test unsupported device doesn't return a trigger config.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")}, @@ -444,12 +448,13 @@ async def test_validate_trigger_unsupported_device( async def test_validate_trigger_unsupported_trigger( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test unsupported trigger does not return a trigger config.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")}, @@ -487,12 +492,13 @@ async def test_validate_trigger_unsupported_trigger( async def test_attach_trigger_no_matching_event( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test no matching event for device doesn't return a trigger config.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")}, diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 3ac682b78a6..cc5d2520f5d 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -139,7 +139,9 @@ async def setup_deconz_integration( async def test_gateway_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" with patch( @@ -178,7 +180,6 @@ async def test_gateway_setup( assert forward_entry_setup.mock_calls[12][1] == (config_entry, SIREN_DOMAIN) assert forward_entry_setup.mock_calls[13][1] == (config_entry, SWITCH_DOMAIN) - device_registry = dr.async_get(hass) gateway_entry = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, gateway.bridgeid)} ) @@ -188,7 +189,9 @@ async def test_gateway_setup( async def test_gateway_device_configuration_url_when_addon( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" with patch( @@ -200,7 +203,6 @@ async def test_gateway_device_configuration_url_when_addon( ) gateway = get_gateway_from_config_entry(hass, config_entry) - device_registry = dr.async_get(hass) gateway_entry = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, gateway.bridgeid)} ) diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index f456508a6f3..58cb7129037 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -159,7 +159,9 @@ async def test_unload_entry_multiple_gateways_parallel( assert len(hass.data[DECONZ_DOMAIN]) == 0 -async def test_update_group_unique_id(hass: HomeAssistant) -> None: +async def test_update_group_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test successful migration of entry data.""" old_unique_id = "123" new_unique_id = "1234" @@ -174,9 +176,8 @@ async def test_update_group_unique_id(hass: HomeAssistant) -> None: }, ) - registry = er.async_get(hass) # Create entity entry to migrate to new unique ID - registry.async_get_or_create( + entity_registry.async_get_or_create( LIGHT_DOMAIN, DECONZ_DOMAIN, f"{old_unique_id}-OLD", @@ -184,7 +185,7 @@ async def test_update_group_unique_id(hass: HomeAssistant) -> None: config_entry=entry, ) # Create entity entry with new unique ID - registry.async_get_or_create( + entity_registry.async_get_or_create( LIGHT_DOMAIN, DECONZ_DOMAIN, f"{new_unique_id}-NEW", @@ -195,11 +196,19 @@ async def test_update_group_unique_id(hass: HomeAssistant) -> None: await async_update_group_unique_id(hass, entry) assert entry.data == {CONF_API_KEY: "1", CONF_HOST: "2", CONF_PORT: "3"} - assert registry.async_get(f"{LIGHT_DOMAIN}.old").unique_id == f"{new_unique_id}-OLD" - assert registry.async_get(f"{LIGHT_DOMAIN}.new").unique_id == f"{new_unique_id}-NEW" + assert ( + entity_registry.async_get(f"{LIGHT_DOMAIN}.old").unique_id + == f"{new_unique_id}-OLD" + ) + assert ( + entity_registry.async_get(f"{LIGHT_DOMAIN}.new").unique_id + == f"{new_unique_id}-NEW" + ) -async def test_update_group_unique_id_no_legacy_group_id(hass: HomeAssistant) -> None: +async def test_update_group_unique_id_no_legacy_group_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test migration doesn't trigger without old legacy group id in entry data.""" old_unique_id = "123" new_unique_id = "1234" @@ -209,9 +218,8 @@ async def test_update_group_unique_id_no_legacy_group_id(hass: HomeAssistant) -> data={}, ) - registry = er.async_get(hass) # Create entity entry to migrate to new unique ID - registry.async_get_or_create( + entity_registry.async_get_or_create( LIGHT_DOMAIN, DECONZ_DOMAIN, f"{old_unique_id}-OLD", @@ -221,4 +229,7 @@ async def test_update_group_unique_id_no_legacy_group_id(hass: HomeAssistant) -> await async_update_group_unique_id(hass, entry) - assert registry.async_get(f"{LIGHT_DOMAIN}.old").unique_id == f"{old_unique_id}-OLD" + assert ( + entity_registry.async_get(f"{LIGHT_DOMAIN}.old").unique_id + == f"{old_unique_id}-OLD" + ) diff --git a/tests/components/deconz/test_logbook.py b/tests/components/deconz/test_logbook.py index eb1d4fc7fef..4d2043923bd 100644 --- a/tests/components/deconz/test_logbook.py +++ b/tests/components/deconz/test_logbook.py @@ -27,7 +27,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_humanifying_deconz_alarm_event( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test humanifying deCONZ event.""" data = { @@ -61,8 +63,6 @@ async def test_humanifying_deconz_alarm_event( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - keypad_event_id = slugify(data["sensors"]["1"]["name"]) keypad_serial = serial_from_unique_id(data["sensors"]["1"]["uniqueid"]) keypad_entry = device_registry.async_get_device( @@ -112,7 +112,9 @@ async def test_humanifying_deconz_alarm_event( async def test_humanifying_deconz_event( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test humanifying deCONZ event.""" data = { @@ -152,8 +154,6 @@ async def test_humanifying_deconz_event( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - switch_event_id = slugify(data["sensors"]["1"]["name"]) switch_serial = serial_from_unique_id(data["sensors"]["1"]["uniqueid"]) switch_entry = device_registry.async_get_device( diff --git a/tests/components/deconz/test_number.py b/tests/components/deconz/test_number.py index 98c8cbaed8d..17cbc2917ec 100644 --- a/tests/components/deconz/test_number.py +++ b/tests/components/deconz/test_number.py @@ -111,17 +111,17 @@ TEST_DATA = [ async def test_number_entities( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_deconz_websocket, sensor_data, expected, ) -> None: """Test successful creation of number entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) # Create entity entry to migrate to new unique ID if "old_unique_id" in expected: - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( NUMBER_DOMAIN, DECONZ_DOMAIN, expected["old_unique_id"], @@ -141,14 +141,14 @@ async def test_number_entities( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py index bde76017817..7d16f0bd513 100644 --- a/tests/components/deconz/test_scene.py +++ b/tests/components/deconz/test_scene.py @@ -57,12 +57,14 @@ TEST_DATA = [ @pytest.mark.parametrize(("raw_data", "expected"), TEST_DATA) async def test_scenes( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, raw_data, expected + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + raw_data, + expected, ) -> None: """Test successful creation of scene entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - with patch.dict(DECONZ_WEB_REQUEST, raw_data): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -75,14 +77,14 @@ async def test_scenes( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_select.py b/tests/components/deconz/test_select.py index fd625d78aed..7b7a9c86168 100644 --- a/tests/components/deconz/test_select.py +++ b/tests/components/deconz/test_select.py @@ -168,12 +168,14 @@ TEST_DATA = [ @pytest.mark.parametrize(("raw_data", "expected"), TEST_DATA) async def test_select( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, raw_data, expected + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + raw_data, + expected, ) -> None: """Test successful creation of button entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - with patch.dict(DECONZ_WEB_REQUEST, raw_data): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -186,14 +188,14 @@ async def test_select( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 4d93df17ba3..7fa93266aef 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -792,18 +792,18 @@ TEST_DATA = [ @pytest.mark.parametrize(("sensor_data", "expected"), TEST_DATA) async def test_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket, sensor_data, expected, ) -> None: """Test successful creation of sensor entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) # Create entity entry to migrate to new unique ID if "old_unique_id" in expected: - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DECONZ_DOMAIN, expected["old_unique_id"], @@ -817,7 +817,9 @@ async def test_sensors( # Enable in entity registry if expected.get("enable_entity"): - ent_reg.async_update_entity(entity_id=expected["entity_id"], disabled_by=None) + entity_registry.async_update_entity( + entity_id=expected["entity_id"], disabled_by=None + ) await hass.async_block_till_done() async_fire_time_changed( @@ -836,16 +838,16 @@ async def test_sensors( # Verify entity registry assert ( - ent_reg.async_get(expected["entity_id"]).entity_category + entity_registry.async_get(expected["entity_id"]).entity_category is expected["entity_category"] ) - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 3171746716d..ade7aba2346 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -349,7 +349,10 @@ async def test_service_refresh_devices_trigger_no_state_update( async def test_remove_orphaned_entries_service( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service works and also don't remove more than expected.""" data = { @@ -374,7 +377,6 @@ async def test_remove_orphaned_entries_service( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "123")}, @@ -391,7 +393,6 @@ async def test_remove_orphaned_entries_service( == 5 # Host, gateway, light, switch and orphan ) - entity_registry = er.async_get(hass) entity_registry.async_get_or_create( SENSOR_DOMAIN, DECONZ_DOMAIN, diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py index 7c3a3498935..31555a71011 100644 --- a/tests/components/deconz/test_switch.py +++ b/tests/components/deconz/test_switch.py @@ -119,13 +119,14 @@ async def test_power_plugs( async def test_remove_legacy_on_off_output_as_light( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test that switch platform cleans up legacy light entities.""" unique_id = "00:00:00:00:00:00:00:00-00" - registry = er.async_get(hass) - switch_light_entity = registry.async_get_or_create( + switch_light_entity = entity_registry.async_get_or_create( LIGHT_DOMAIN, DECONZ_DOMAIN, unique_id ) @@ -144,6 +145,6 @@ async def test_remove_legacy_on_off_output_as_light( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - assert not registry.async_get("light.on_off_output_device") - assert registry.async_get("switch.on_off_output_device") + assert not entity_registry.async_get("light.on_off_output_device") + assert entity_registry.async_get("switch.on_off_output_device") assert len(hass.states.async_all()) == 1 From 0a57968fdc39603f0c1791e02850c5a26d208d8c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 9 Nov 2023 19:01:07 +0100 Subject: [PATCH 360/982] Update nut sensor tests to use parametrize (#103707) * Update nut sensor tests to use parametrize * Add missing model * Check on unique_id instead of config_entry_id * Separate tests --- tests/components/nut/test_sensor.py | 220 +++++----------------------- 1 file changed, 37 insertions(+), 183 deletions(-) diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 4ec1e3c47ca..014e683b30c 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -1,6 +1,8 @@ """The sensor tests for the nut platform.""" from unittest.mock import patch +import pytest + from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_HOST, @@ -17,37 +19,25 @@ from .util import _get_mock_pynutclient, async_init_integration from tests.common import MockConfigEntry -async def test_pr3000rt2u(hass: HomeAssistant) -> None: - """Test creation of PR3000RT2U sensors.""" +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_devices( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Test creation of device sensors.""" - await async_init_integration(hass, "PR3000RT2U") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == "CPS_PR3000RT2U_PYVJO2000034_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_cp1350c(hass: HomeAssistant) -> None: - """Test creation of CP1350C sensors.""" - - config_entry = await async_init_integration(hass, "CP1350C") - - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") + config_entry = await async_init_integration(hass, model) + entry = entity_registry.async_get("sensor.ups1_battery_charge") assert entry assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" @@ -66,161 +56,25 @@ async def test_cp1350c(hass: HomeAssistant) -> None: ) -async def test_5e850i(hass: HomeAssistant) -> None: - """Test creation of 5E850I sensors.""" +@pytest.mark.parametrize( + ("model", "unique_id"), + [ + ("PR3000RT2U", "CPS_PR3000RT2U_PYVJO2000034_battery.charge"), + ( + "BACKUPSES600M1", + "American Power Conversion_Back-UPS ES 600M1_4B1713P32195 _battery.charge", + ), + ], +) +async def test_devices_with_unique_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, unique_id: str +) -> None: + """Test creation of device sensors with unique ids.""" - config_entry = await async_init_integration(hass, "5E850I") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") + await async_init_integration(hass, model) + entry = entity_registry.async_get("sensor.ups1_battery_charge") assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_5e650i(hass: HomeAssistant) -> None: - """Test creation of 5E650I sensors.""" - - config_entry = await async_init_integration(hass, "5E650I") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_backupsses600m1(hass: HomeAssistant) -> None: - """Test creation of BACKUPSES600M1 sensors.""" - - await async_init_integration(hass, "BACKUPSES600M1") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert ( - entry.unique_id - == "American Power Conversion_Back-UPS ES 600M1_4B1713P32195 _battery.charge" - ) - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_cp1500pfclcd(hass: HomeAssistant) -> None: - """Test creation of CP1500PFCLCD sensors.""" - - config_entry = await async_init_integration(hass, "CP1500PFCLCD") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_dl650elcd(hass: HomeAssistant) -> None: - """Test creation of DL650ELCD sensors.""" - - config_entry = await async_init_integration(hass, "DL650ELCD") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_eaton5p1550(hass: HomeAssistant) -> None: - """Test creation of EATON5P1550 sensors.""" - - config_entry = await async_init_integration(hass, "EATON5P1550") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_blazer_usb(hass: HomeAssistant) -> None: - """Test creation of blazer_usb sensors.""" - - config_entry = await async_init_integration(hass, "blazer_usb") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" + assert entry.unique_id == unique_id state = hass.states.get("sensor.ups1_battery_charge") assert state.state == "100" From 5ee826528d38afb2adfb03a348ede53b72b83ef0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Nov 2023 12:46:34 -0600 Subject: [PATCH 361/982] Bump zeroconf to 0.122.3 (#103657) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index dae92ef5aa3..df763e7db8b 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.120.0"] + "requirements": ["zeroconf==0.122.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 03dd947e098..6ed0bf77abc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -56,7 +56,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.120.0 +zeroconf==0.122.3 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 72fe9df3370..daf4947ad2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2797,7 +2797,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.120.0 +zeroconf==0.122.3 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34a82175d01..8692b87492c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2091,7 +2091,7 @@ yt-dlp==2023.10.13 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.120.0 +zeroconf==0.122.3 # homeassistant.components.zeversolar zeversolar==0.3.1 From b81f15725fe359285e1c4257ab5ac3870f65ad52 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 9 Nov 2023 19:47:23 +0100 Subject: [PATCH 362/982] Update bond tests to use entity & device registry fixtures (#103708) --- tests/components/bond/test_button.py | 15 ++++---- tests/components/bond/test_cover.py | 9 ++--- tests/components/bond/test_fan.py | 31 ++++++++++------- tests/components/bond/test_init.py | 27 ++++++++------- tests/components/bond/test_light.py | 51 +++++++++++++++++----------- tests/components/bond/test_switch.py | 9 ++--- 6 files changed, 82 insertions(+), 60 deletions(-) diff --git a/tests/components/bond/test_button.py b/tests/components/bond/test_button.py index 9878050e6cf..6984831626d 100644 --- a/tests/components/bond/test_button.py +++ b/tests/components/bond/test_button.py @@ -6,7 +6,6 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRE from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from .common import patch_bond_action, patch_bond_device_state, setup_platform @@ -57,7 +56,10 @@ def light(name: str): } -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform( hass, @@ -67,14 +69,13 @@ async def test_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["button.name_1_stop_actions"] + entity = entity_registry.entities["button.name_1_stop_actions"] assert entity.unique_id == "test-hub-id_test-device-id_stop" - entity = registry.entities["button.name_1_start_increasing_brightness"] + entity = entity_registry.entities["button.name_1_start_increasing_brightness"] assert entity.unique_id == "test-hub-id_test-device-id_startincreasingbrightness" - entity = registry.entities["button.name_1_start_decreasing_brightness"] + entity = entity_registry.entities["button.name_1_start_decreasing_brightness"] assert entity.unique_id == "test-hub-id_test-device-id_startdecreasingbrightness" - entity = registry.entities["button.name_1_start_dimmer"] + entity = entity_registry.entities["button.name_1_start_dimmer"] assert entity.unique_id == "test-hub-id_test-device-id_startdimmer" diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index 8f3e9c09922..e489f8550d6 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -23,7 +23,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( @@ -72,7 +71,10 @@ def tilt_shades(name: str): } -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform( hass, @@ -82,8 +84,7 @@ async def test_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["cover.name_1"] + entity = entity_registry.entities["cover.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index f2fa109af22..db1c0fc787d 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -36,7 +36,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( @@ -81,7 +80,11 @@ async def turn_fan_on( await hass.async_block_till_done() -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform( hass, @@ -91,11 +94,9 @@ async def test_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["fan.name_1"] + entity = entity_registry.entities["fan.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" - device_registry = dr.async_get(hass) device = device_registry.async_get(entity.device_id) assert device.configuration_url == "http://some host" @@ -476,7 +477,11 @@ async def test_fan_available(hass: HomeAssistant) -> None: ) -async def test_setup_smart_by_bond_fan(hass: HomeAssistant) -> None: +async def test_setup_smart_by_bond_fan( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test setting up a fan without a hub.""" config_entry = await setup_platform( hass, @@ -491,10 +496,8 @@ async def test_setup_smart_by_bond_fan(hass: HomeAssistant) -> None: }, ) assert hass.states.get("fan.name_1") is not None - registry = er.async_get(hass) - entry = registry.async_get("fan.name_1") + entry = entity_registry.async_get("fan.name_1") assert entry.device_id is not None - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device is not None assert device.sw_version == "test-version" @@ -505,7 +508,11 @@ async def test_setup_smart_by_bond_fan(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_setup_hub_template_fan(hass: HomeAssistant) -> None: +async def test_setup_hub_template_fan( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test setting up a fan on a hub created from a template.""" config_entry = await setup_platform( hass, @@ -521,10 +528,8 @@ async def test_setup_hub_template_fan(hass: HomeAssistant) -> None: }, ) assert hass.states.get("fan.name_1") is not None - registry = er.async_get(hass) - entry = registry.async_get("fan.name_1") + entry = entity_registry.async_get("fan.name_1") assert entry.device_id is not None - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device is not None assert device.sw_version is None diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 33919219301..92c11028173 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ASSUMED_STATE, CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .common import ( @@ -82,6 +81,7 @@ async def test_async_setup_raises_fails_if_auth_fails(hass: HomeAssistant) -> No async def test_async_setup_entry_sets_up_hub_and_supported_domains( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Test that configuring entry sets up cover domain.""" config_entry = MockConfigEntry( @@ -112,7 +112,6 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains( assert config_entry.unique_id == "ZXXX12345" # verify hub device is registered correctly - device_registry = dr.async_get(hass) hub = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert hub.name == "bond-name" assert hub.manufacturer == "Olibra" @@ -153,7 +152,9 @@ async def test_unload_config_entry(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: +async def test_old_identifiers_are_removed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we remove the old non-unique identifiers.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -163,7 +164,6 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: old_identifers = (DOMAIN, "device_id") new_identifiers = (DOMAIN, "ZXXX12345", "device_id") - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={old_identifers}, @@ -201,7 +201,9 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: assert device_registry.async_get_device(identifiers={new_identifiers}) is not None -async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant) -> None: +async def test_smart_by_bond_device_suggested_area( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we can setup a smart by bond device and get the suggested area.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -238,13 +240,14 @@ async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == "KXXX12345" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "KXXX12345")}) assert device is not None assert device.suggested_area == "Den" -async def test_bridge_device_suggested_area(hass: HomeAssistant) -> None: +async def test_bridge_device_suggested_area( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we can setup a bridge bond device and get the suggested area.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -284,14 +287,16 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == "ZXXX12345" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert device is not None assert device.suggested_area == "Office" async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) @@ -304,11 +309,9 @@ async def test_device_remove_devices( bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["fan.name_1"] + entity = entity_registry.entities["fan.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) assert ( await remove_device( diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 6cbd43b221b..10395f395dd 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -32,7 +32,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( @@ -153,7 +152,10 @@ def light_brightness_increase_decrease_only(name: str): } -async def test_fan_entity_registry(hass: HomeAssistant) -> None: +async def test_fan_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that fan with light devices are registered in the entity registry.""" await setup_platform( hass, @@ -163,12 +165,14 @@ async def test_fan_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.fan_name"] + entity = entity_registry.entities["light.fan_name"] assert entity.unique_id == "test-hub-id_test-device-id" -async def test_fan_up_light_entity_registry(hass: HomeAssistant) -> None: +async def test_fan_up_light_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that fan with up light devices are registered in the entity registry.""" await setup_platform( hass, @@ -178,12 +182,14 @@ async def test_fan_up_light_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.fan_name_up_light"] + entity = entity_registry.entities["light.fan_name_up_light"] assert entity.unique_id == "test-hub-id_test-device-id_up_light" -async def test_fan_down_light_entity_registry(hass: HomeAssistant) -> None: +async def test_fan_down_light_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that fan with down light devices are registered in the entity registry.""" await setup_platform( hass, @@ -193,12 +199,14 @@ async def test_fan_down_light_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.fan_name_down_light"] + entity = entity_registry.entities["light.fan_name_down_light"] assert entity.unique_id == "test-hub-id_test-device-id_down_light" -async def test_fireplace_entity_registry(hass: HomeAssistant) -> None: +async def test_fireplace_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that flame fireplace devices are registered in the entity registry.""" await setup_platform( hass, @@ -208,12 +216,14 @@ async def test_fireplace_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.fireplace_name"] + entity = entity_registry.entities["light.fireplace_name"] assert entity.unique_id == "test-hub-id_test-device-id" -async def test_fireplace_with_light_entity_registry(hass: HomeAssistant) -> None: +async def test_fireplace_with_light_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that flame+light devices are registered in the entity registry.""" await setup_platform( hass, @@ -223,14 +233,16 @@ async def test_fireplace_with_light_entity_registry(hass: HomeAssistant) -> None bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity_flame = registry.entities["light.fireplace_name"] + entity_flame = entity_registry.entities["light.fireplace_name"] assert entity_flame.unique_id == "test-hub-id_test-device-id" - entity_light = registry.entities["light.fireplace_name_light"] + entity_light = entity_registry.entities["light.fireplace_name_light"] assert entity_light.unique_id == "test-hub-id_test-device-id_light" -async def test_light_entity_registry(hass: HomeAssistant) -> None: +async def test_light_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests lights are registered in the entity registry.""" await setup_platform( hass, @@ -240,8 +252,7 @@ async def test_light_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.light_name"] + entity = entity_registry.entities["light.light_name"] assert entity.unique_id == "test-hub-id_test-device-id" diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 06d2e0b4c64..1ab9ef2165c 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -14,7 +14,6 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( @@ -33,7 +32,10 @@ def generic_device(name: str): return {"name": name, "type": DeviceType.GENERIC_DEVICE} -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform( hass, @@ -43,8 +45,7 @@ async def test_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["switch.name_1"] + entity = entity_registry.entities["switch.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" From 04e0e2bd75e8315c665c8459823a525710992232 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Nov 2023 20:46:20 +0100 Subject: [PATCH 363/982] Update a* tests to use device & entity registry fixtures (#103711) --- .../abode/test_alarm_control_panel.py | 5 +- tests/components/abode/test_binary_sensor.py | 5 +- tests/components/abode/test_camera.py | 5 +- tests/components/abode/test_cover.py | 5 +- tests/components/abode/test_light.py | 5 +- tests/components/abode/test_lock.py | 5 +- tests/components/abode/test_sensor.py | 5 +- tests/components/abode/test_switch.py | 5 +- tests/components/accuweather/test_init.py | 10 +-- tests/components/accuweather/test_sensor.py | 86 ++++++++++--------- tests/components/accuweather/test_weather.py | 14 +-- .../advantage_air/test_binary_sensor.py | 22 ++--- .../components/advantage_air/test_climate.py | 12 +-- tests/components/advantage_air/test_cover.py | 16 ++-- tests/components/advantage_air/test_light.py | 20 +++-- tests/components/advantage_air/test_select.py | 8 +- tests/components/advantage_air/test_sensor.py | 22 ++--- tests/components/advantage_air/test_switch.py | 16 ++-- tests/components/advantage_air/test_update.py | 8 +- tests/components/aemet/test_weather.py | 4 +- tests/components/airly/test_init.py | 10 +-- tests/components/airly/test_sensor.py | 29 ++++--- tests/components/airvisual/test_init.py | 5 +- tests/components/airzone/test_init.py | 6 +- tests/components/alexa/test_entities.py | 5 +- .../components/apcupsd/test_binary_sensor.py | 7 +- tests/components/apcupsd/test_init.py | 12 +-- tests/components/apcupsd/test_sensor.py | 24 +++--- .../components/assist_pipeline/test_select.py | 4 +- tests/components/atag/test_climate.py | 5 +- tests/components/atag/test_sensors.py | 9 +- tests/components/atag/test_water_heater.py | 9 +- tests/components/august/test_binary_sensor.py | 6 +- tests/components/august/test_init.py | 10 +-- tests/components/august/test_lock.py | 16 ++-- tests/components/august/test_sensor.py | 50 ++++++----- tests/components/awair/test_init.py | 7 +- tests/components/awair/test_sensor.py | 84 +++++++++++------- tests/components/axis/test_device.py | 7 +- 39 files changed, 321 insertions(+), 262 deletions(-) diff --git a/tests/components/abode/test_alarm_control_panel.py b/tests/components/abode/test_alarm_control_panel.py index 6924c440bb4..c5500717c5a 100644 --- a/tests/components/abode/test_alarm_control_panel.py +++ b/tests/components/abode/test_alarm_control_panel.py @@ -24,10 +24,11 @@ from .common import setup_platform DEVICE_ID = "alarm_control_panel.abode_alarm" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, ALARM_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) # Abode alarm device unique_id is the MAC address diff --git a/tests/components/abode/test_binary_sensor.py b/tests/components/abode/test_binary_sensor.py index 6d7ffec438b..987eea7d891 100644 --- a/tests/components/abode/test_binary_sensor.py +++ b/tests/components/abode/test_binary_sensor.py @@ -17,10 +17,11 @@ from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, BINARY_SENSOR_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("binary_sensor.front_door") assert entry.unique_id == "2834013428b6035fba7d4054aa7b25a3" diff --git a/tests/components/abode/test_camera.py b/tests/components/abode/test_camera.py index 4bfc16d9689..d0c47eff045 100644 --- a/tests/components/abode/test_camera.py +++ b/tests/components/abode/test_camera.py @@ -10,10 +10,11 @@ from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, CAMERA_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("camera.test_cam") assert entry.unique_id == "d0a3a1c316891ceb00c20118aae2a133" diff --git a/tests/components/abode/test_cover.py b/tests/components/abode/test_cover.py index a187c0c447e..bc3abd32cd1 100644 --- a/tests/components/abode/test_cover.py +++ b/tests/components/abode/test_cover.py @@ -18,10 +18,11 @@ from .common import setup_platform DEVICE_ID = "cover.garage_door" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, COVER_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) assert entry.unique_id == "61cbz3b542d2o33ed2fz02721bda3324" diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index 56a924c1226..d7fd719a2b9 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -27,10 +27,11 @@ from .common import setup_platform DEVICE_ID = "light.living_room_lamp" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, LIGHT_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) assert entry.unique_id == "741385f4388b2637df4c6b398fe50581" diff --git a/tests/components/abode/test_lock.py b/tests/components/abode/test_lock.py index ca1a4794bdb..ac988a1ee12 100644 --- a/tests/components/abode/test_lock.py +++ b/tests/components/abode/test_lock.py @@ -18,10 +18,11 @@ from .common import setup_platform DEVICE_ID = "lock.test_lock" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, LOCK_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) assert entry.unique_id == "51cab3b545d2o34ed7fz02731bda5324" diff --git a/tests/components/abode/test_sensor.py b/tests/components/abode/test_sensor.py index 755dfbf584e..9f4b3374fc2 100644 --- a/tests/components/abode/test_sensor.py +++ b/tests/components/abode/test_sensor.py @@ -14,10 +14,11 @@ from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, SENSOR_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.environment_sensor_humidity") assert entry.unique_id == "13545b21f4bdcd33d9abd461f8443e65-humidity" diff --git a/tests/components/abode/test_switch.py b/tests/components/abode/test_switch.py index a18e554aa39..b5b93d05481 100644 --- a/tests/components/abode/test_switch.py +++ b/tests/components/abode/test_switch.py @@ -24,10 +24,11 @@ DEVICE_ID = "switch.test_switch" DEVICE_UID = "0012a4d3614cb7e2b8c9abea31d2fb2a" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, SWITCH_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(AUTOMATION_ID) assert entry.unique_id == AUTOMATION_UID diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index c7f79b487b5..342cc2f5914 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -117,11 +117,11 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None: assert mock_forecast.call_count == 1 -async def test_remove_ozone_sensors(hass: HomeAssistant) -> None: +async def test_remove_ozone_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test remove ozone sensors from registry.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_PLATFORM, DOMAIN, "0123456-ozone-0", @@ -131,5 +131,5 @@ async def test_remove_ozone_sensors(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("sensor.home_ozone_0d") + entry = entity_registry.async_get("sensor.home_ozone_0d") assert entry is None diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index a7a94894be4..eb5e26a8e20 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -42,11 +42,12 @@ from tests.common import ( async def test_sensor_without_forecast( - hass: HomeAssistant, entity_registry_enabled_by_default: None + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + entity_registry: er.EntityRegistry, ) -> None: """Test states of the sensor without forecast.""" await init_integration(hass) - registry = er.async_get(hass) state = hass.states.get("sensor.home_cloud_ceiling") assert state @@ -57,7 +58,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE - entry = registry.async_get("sensor.home_cloud_ceiling") + entry = entity_registry.async_get("sensor.home_cloud_ceiling") assert entry assert entry.unique_id == "0123456-ceiling" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -78,7 +79,7 @@ async def test_sensor_without_forecast( == SensorDeviceClass.PRECIPITATION_INTENSITY ) - entry = registry.async_get("sensor.home_precipitation") + entry = entity_registry.async_get("sensor.home_precipitation") assert entry assert entry.unique_id == "0123456-precipitation" @@ -91,7 +92,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_OPTIONS) == ["falling", "rising", "steady"] - entry = registry.async_get("sensor.home_pressure_tendency") + entry = entity_registry.async_get("sensor.home_pressure_tendency") assert entry assert entry.unique_id == "0123456-pressuretendency" assert entry.translation_key == "pressure_tendency" @@ -104,7 +105,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_realfeel_temperature") + entry = entity_registry.async_get("sensor.home_realfeel_temperature") assert entry assert entry.unique_id == "0123456-realfeeltemperature" @@ -116,7 +117,7 @@ async def test_sensor_without_forecast( assert state.attributes.get("level") == "High" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_uv_index") + entry = entity_registry.async_get("sensor.home_uv_index") assert entry assert entry.unique_id == "0123456-uvindex" @@ -128,7 +129,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_apparent_temperature") + entry = entity_registry.async_get("sensor.home_apparent_temperature") assert entry assert entry.unique_id == "0123456-apparenttemperature" @@ -140,7 +141,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_cloud_cover") + entry = entity_registry.async_get("sensor.home_cloud_cover") assert entry assert entry.unique_id == "0123456-cloudcover" @@ -152,7 +153,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_dew_point") + entry = entity_registry.async_get("sensor.home_dew_point") assert entry assert entry.unique_id == "0123456-dewpoint" @@ -164,7 +165,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_realfeel_temperature_shade") + entry = entity_registry.async_get("sensor.home_realfeel_temperature_shade") assert entry assert entry.unique_id == "0123456-realfeeltemperatureshade" @@ -176,7 +177,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_wet_bulb_temperature") + entry = entity_registry.async_get("sensor.home_wet_bulb_temperature") assert entry assert entry.unique_id == "0123456-wetbulbtemperature" @@ -188,7 +189,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_wind_chill_temperature") + entry = entity_registry.async_get("sensor.home_wind_chill_temperature") assert entry assert entry.unique_id == "0123456-windchilltemperature" @@ -204,7 +205,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust_speed") + entry = entity_registry.async_get("sensor.home_wind_gust_speed") assert entry assert entry.unique_id == "0123456-windgust" @@ -220,17 +221,18 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_speed") + entry = entity_registry.async_get("sensor.home_wind_speed") assert entry assert entry.unique_id == "0123456-wind" async def test_sensor_with_forecast( - hass: HomeAssistant, entity_registry_enabled_by_default: None + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + entity_registry: er.EntityRegistry, ) -> None: """Test states of the sensor with forecast.""" await init_integration(hass, forecast=True) - registry = er.async_get(hass) state = hass.states.get("sensor.home_hours_of_sun_today") assert state @@ -240,7 +242,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_hours_of_sun_today") + entry = entity_registry.async_get("sensor.home_hours_of_sun_today") assert entry assert entry.unique_id == "0123456-hoursofsun-0" @@ -252,7 +254,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_max_today") + entry = entity_registry.async_get("sensor.home_realfeel_temperature_max_today") assert entry state = hass.states.get("sensor.home_realfeel_temperature_min_today") @@ -263,7 +265,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_min_today") + entry = entity_registry.async_get("sensor.home_realfeel_temperature_min_today") assert entry assert entry.unique_id == "0123456-realfeeltemperaturemin-0" @@ -275,7 +277,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_thunderstorm_probability_today") + entry = entity_registry.async_get("sensor.home_thunderstorm_probability_today") assert entry assert entry.unique_id == "0123456-thunderstormprobabilityday-0" @@ -287,7 +289,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_thunderstorm_probability_tonight") + entry = entity_registry.async_get("sensor.home_thunderstorm_probability_tonight") assert entry assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" @@ -300,7 +302,7 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "moderate" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_uv_index_today") + entry = entity_registry.async_get("sensor.home_uv_index_today") assert entry assert entry.unique_id == "0123456-uvindex-0" @@ -327,7 +329,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_cloud_cover_today") + entry = entity_registry.async_get("sensor.home_cloud_cover_today") assert entry assert entry.unique_id == "0123456-cloudcoverday-0" @@ -339,7 +341,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_cloud_cover_tonight") + entry = entity_registry.async_get("sensor.home_cloud_cover_tonight") assert entry state = hass.states.get("sensor.home_grass_pollen_today") @@ -354,7 +356,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:grass" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_grass_pollen_today") + entry = entity_registry.async_get("sensor.home_grass_pollen_today") assert entry assert entry.unique_id == "0123456-grass-0" @@ -369,7 +371,7 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:blur" - entry = registry.async_get("sensor.home_mold_pollen_today") + entry = entity_registry.async_get("sensor.home_mold_pollen_today") assert entry assert entry.unique_id == "0123456-mold-0" @@ -384,7 +386,7 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:sprout" - entry = registry.async_get("sensor.home_ragweed_pollen_today") + entry = entity_registry.async_get("sensor.home_ragweed_pollen_today") assert entry assert entry.unique_id == "0123456-ragweed-0" @@ -396,7 +398,9 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_shade_max_today") + entry = entity_registry.async_get( + "sensor.home_realfeel_temperature_shade_max_today" + ) assert entry assert entry.unique_id == "0123456-realfeeltemperatureshademax-0" @@ -407,7 +411,9 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - entry = registry.async_get("sensor.home_realfeel_temperature_shade_min_today") + entry = entity_registry.async_get( + "sensor.home_realfeel_temperature_shade_min_today" + ) assert entry assert entry.unique_id == "0123456-realfeeltemperatureshademin-0" @@ -423,7 +429,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:tree-outline" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_tree_pollen_today") + entry = entity_registry.async_get("sensor.home_tree_pollen_today") assert entry assert entry.unique_id == "0123456-tree-0" @@ -439,7 +445,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_speed_today") + entry = entity_registry.async_get("sensor.home_wind_speed_today") assert entry assert entry.unique_id == "0123456-windday-0" @@ -456,7 +462,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_speed_tonight") + entry = entity_registry.async_get("sensor.home_wind_speed_tonight") assert entry assert entry.unique_id == "0123456-windnight-0" @@ -473,7 +479,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust_speed_today") + entry = entity_registry.async_get("sensor.home_wind_gust_speed_today") assert entry assert entry.unique_id == "0123456-windgustday-0" @@ -490,11 +496,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust_speed_tonight") + entry = entity_registry.async_get("sensor.home_wind_gust_speed_tonight") assert entry assert entry.unique_id == "0123456-windgustnight-0" - entry = registry.async_get("sensor.home_air_quality_today") + entry = entity_registry.async_get("sensor.home_air_quality_today") assert entry assert entry.unique_id == "0123456-airquality-0" @@ -508,7 +514,7 @@ async def test_sensor_with_forecast( == UnitOfIrradiance.WATTS_PER_SQUARE_METER ) - entry = registry.async_get("sensor.home_solar_irradiance_today") + entry = entity_registry.async_get("sensor.home_solar_irradiance_today") assert entry assert entry.unique_id == "0123456-solarirradianceday-0" @@ -522,7 +528,7 @@ async def test_sensor_with_forecast( == UnitOfIrradiance.WATTS_PER_SQUARE_METER ) - entry = registry.async_get("sensor.home_solar_irradiance_tonight") + entry = entity_registry.async_get("sensor.home_solar_irradiance_tonight") assert entry assert entry.unique_id == "0123456-solarirradiancenight-0" @@ -534,7 +540,7 @@ async def test_sensor_with_forecast( ) assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - entry = registry.async_get("sensor.home_condition_today") + entry = entity_registry.async_get("sensor.home_condition_today") assert entry assert entry.unique_id == "0123456-longphraseday-0" @@ -543,7 +549,7 @@ async def test_sensor_with_forecast( assert state.state == "Partly cloudy" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - entry = registry.async_get("sensor.home_condition_tonight") + entry = entity_registry.async_get("sensor.home_condition_tonight") assert entry assert entry.unique_id == "0123456-longphrasenight-0" diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 1d970e322e4..5a35f2798d8 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -55,10 +55,11 @@ from tests.common import ( from tests.typing import WebSocketGenerator -async def test_weather_without_forecast(hass: HomeAssistant) -> None: +async def test_weather_without_forecast( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the weather without forecast.""" await init_integration(hass) - registry = er.async_get(hass) state = hass.states.get("weather.home") assert state @@ -78,15 +79,16 @@ async def test_weather_without_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ATTR_SUPPORTED_FEATURES not in state.attributes - entry = registry.async_get("weather.home") + entry = entity_registry.async_get("weather.home") assert entry assert entry.unique_id == "0123456" -async def test_weather_with_forecast(hass: HomeAssistant) -> None: +async def test_weather_with_forecast( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the weather with forecast.""" await init_integration(hass, forecast=True) - registry = er.async_get(hass) state = hass.states.get("weather.home") assert state @@ -120,7 +122,7 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert forecast.get(ATTR_FORECAST_WIND_GUST_SPEED) == 29.6 assert forecast.get(ATTR_WEATHER_UV_INDEX) == 5 - entry = registry.async_get("weather.home") + entry = entity_registry.async_get("weather.home") assert entry assert entry.unique_id == "0123456" diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 8f2183d49c5..c6d055f396a 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -20,7 +20,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensor_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test binary sensor setup.""" @@ -34,8 +36,6 @@ async def test_binary_sensor_async_setup_entry( ) await add_mock_config(hass) - registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 # Test First Air Filter @@ -44,7 +44,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-filter" @@ -54,7 +54,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac2-filter" @@ -64,7 +64,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-motion" @@ -74,7 +74,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-motion" @@ -83,7 +83,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) - registry.async_update_entity(entity_id=entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() async_fire_time_changed( @@ -96,7 +96,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-myzone" @@ -105,7 +105,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) - registry.async_update_entity(entity_id=entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() async_fire_time_changed( @@ -118,6 +118,6 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-myzone" diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index f5f12e48a40..a1eb886cbd0 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -49,7 +49,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_climate_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test climate platform.""" @@ -63,8 +65,6 @@ async def test_climate_async_setup_entry( ) await add_mock_config(hass) - registry = er.async_get(hass) - # Test MyZone Climate Entity entity_id = "climate.myzone" state = hass.states.get(entity_id) @@ -75,7 +75,7 @@ async def test_climate_async_setup_entry( assert state.attributes.get(ATTR_TEMPERATURE) == 24 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1" @@ -173,7 +173,7 @@ async def test_climate_async_setup_entry( assert state.attributes.get(ATTR_TEMPERATURE) == 24 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01" @@ -227,7 +227,7 @@ async def test_climate_async_setup_entry( assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20 assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac3" diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index 80162b448d1..af516d16e6e 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -30,7 +30,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_ac_cover( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test cover platform.""" @@ -45,8 +47,6 @@ async def test_ac_cover( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Cover Zone Entity entity_id = "cover.myauto_zone_y" state = hass.states.get(entity_id) @@ -55,7 +55,7 @@ async def test_ac_cover( assert state.attributes.get("device_class") == CoverDeviceClass.DAMPER assert state.attributes.get("current_position") == 100 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac3-z01" @@ -144,7 +144,9 @@ async def test_ac_cover( async def test_things_cover( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test cover platform.""" @@ -159,8 +161,6 @@ async def test_things_cover( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Blind 1 Entity entity_id = "cover.blind_1" thing_id = "200" @@ -169,7 +169,7 @@ async def test_things_cover( assert state.state == STATE_OPEN assert state.attributes.get("device_class") == CoverDeviceClass.BLIND - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-200" diff --git a/tests/components/advantage_air/test_light.py b/tests/components/advantage_air/test_light.py index a1d38857116..0e27b8aec73 100644 --- a/tests/components/advantage_air/test_light.py +++ b/tests/components/advantage_air/test_light.py @@ -27,7 +27,11 @@ from . import ( from tests.test_util.aiohttp import AiohttpClientMocker -async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_light( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, +) -> None: """Test light setup.""" aioclient_mock.get( @@ -41,8 +45,6 @@ async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - await add_mock_config(hass) - registry = er.async_get(hass) - # Test Light Entity entity_id = "light.light_a" light_id = "100" @@ -50,7 +52,7 @@ async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == f"uniqueid-{light_id}" @@ -86,7 +88,7 @@ async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - entity_id = "light.light_b" light_id = "101" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == f"uniqueid-{light_id}" @@ -121,7 +123,9 @@ async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - async def test_things_light( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test things lights.""" @@ -136,8 +140,6 @@ async def test_things_light( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Switch Entity entity_id = "light.thing_light_dimmable" light_id = "204" @@ -145,7 +147,7 @@ async def test_things_light( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-204" diff --git a/tests/components/advantage_air/test_select.py b/tests/components/advantage_air/test_select.py index 9209862f3c9..553c2e60180 100644 --- a/tests/components/advantage_air/test_select.py +++ b/tests/components/advantage_air/test_select.py @@ -22,7 +22,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_select_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test select platform.""" @@ -37,8 +39,6 @@ async def test_select_async_setup_entry( await add_mock_config(hass) - registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 # Test MyZone Select Entity @@ -47,7 +47,7 @@ async def test_select_async_setup_entry( assert state assert state.state == "Zone open with Sensor" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-myzone" diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index d2c290a97de..e4fab12291d 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -26,7 +26,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_platform( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test sensor platform.""" @@ -40,8 +42,6 @@ async def test_sensor_platform( ) await add_mock_config(hass) - registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 # Test First TimeToOn Sensor @@ -50,7 +50,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 0 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-timetoOn" @@ -75,7 +75,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 10 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-timetoOff" @@ -100,7 +100,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 100 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-vent" @@ -110,7 +110,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 0 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-vent" @@ -120,7 +120,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 40 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-signal" @@ -130,7 +130,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 10 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-signal" @@ -139,7 +139,7 @@ async def test_sensor_platform( assert not hass.states.get(entity_id) - registry.async_update_entity(entity_id=entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() async_fire_time_changed( @@ -152,6 +152,6 @@ async def test_sensor_platform( assert state assert int(state.state) == 25 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-temp" diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index 36851037623..99e4c645e71 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -27,7 +27,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_cover_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test switch platform.""" @@ -42,15 +44,13 @@ async def test_cover_async_setup_entry( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Switch Entity entity_id = "switch.myzone_fresh_air" state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-freshair" @@ -82,7 +82,9 @@ async def test_cover_async_setup_entry( async def test_things_switch( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test things switches.""" @@ -97,8 +99,6 @@ async def test_things_switch( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Switch Entity entity_id = "switch.relay" thing_id = "205" @@ -106,7 +106,7 @@ async def test_things_switch( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-205" diff --git a/tests/components/advantage_air/test_update.py b/tests/components/advantage_air/test_update.py index 0e7c7be4436..985641b923b 100644 --- a/tests/components/advantage_air/test_update.py +++ b/tests/components/advantage_air/test_update.py @@ -10,7 +10,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_update_platform( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test update platform.""" @@ -20,13 +22,11 @@ async def test_update_platform( ) await add_mock_config(hass) - registry = er.async_get(hass) - entity_id = "update.testname_app" state = hass.states.get(entity_id) assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid" diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index 67cdbe7805d..f7ab39b9a71 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -81,11 +81,11 @@ async def test_aemet_weather( async def test_aemet_weather_legacy( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, ) -> None: """Test states of legacy weather.""" - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "None hourly", diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 0a3ea927446..f24a75bbb6e 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -232,12 +232,12 @@ async def test_migrate_device_entry( async def test_remove_air_quality_entities( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test remove air_quality entities from registry.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( AIR_QUALITY_PLATFORM, DOMAIN, "123-456", @@ -247,5 +247,5 @@ async def test_remove_air_quality_entities( await init_integration(hass, aioclient_mock) - entry = registry.async_get("air_quality.home") + entry = entity_registry.async_get("air_quality.home") assert entry is None diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 4888176e175..35d7eb86c04 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -33,10 +33,13 @@ from tests.common import async_fire_time_changed, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_sensor( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, +) -> None: """Test states of the sensor.""" await init_integration(hass, aioclient_mock) - registry = er.async_get(hass) state = hass.states.get("sensor.home_common_air_quality_index") assert state @@ -45,7 +48,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get("sensor.home_common_air_quality_index") + entry = entity_registry.async_get("sensor.home_common_air_quality_index") assert entry assert entry.unique_id == "123-456-caqi" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -58,7 +61,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_humidity") + entry = entity_registry.async_get("sensor.home_humidity") assert entry assert entry.unique_id == "123-456-humidity" assert entry.options["sensor"] == {"suggested_display_precision": 1} @@ -74,7 +77,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_pm1") + entry = entity_registry.async_get("sensor.home_pm1") assert entry assert entry.unique_id == "123-456-pm1" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -90,7 +93,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_pm2_5") + entry = entity_registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-456-pm25" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -106,7 +109,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_pm10") + entry = entity_registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-456-pm10" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -122,7 +125,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert entry.options["sensor"] == {"suggested_display_precision": 0} - entry = registry.async_get("sensor.home_carbon_monoxide") + entry = entity_registry.async_get("sensor.home_carbon_monoxide") assert entry assert entry.unique_id == "123-456-co" @@ -137,7 +140,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.NITROGEN_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_nitrogen_dioxide") + entry = entity_registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-456-no2" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -153,7 +156,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.OZONE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_ozone") + entry = entity_registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-456-o3" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -169,7 +172,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SULPHUR_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_sulphur_dioxide") + entry = entity_registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-456-so2" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -182,7 +185,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_pressure") + entry = entity_registry.async_get("sensor.home_pressure") assert entry assert entry.unique_id == "123-456-pressure" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -195,7 +198,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_temperature") + entry = entity_registry.async_get("sensor.home_temperature") assert entry assert entry.unique_id == "123-456-temperature" assert entry.options["sensor"] == {"suggested_display_precision": 1} diff --git a/tests/components/airvisual/test_init.py b/tests/components/airvisual/test_init.py index 7515ad832ce..4f71e75da1e 100644 --- a/tests/components/airvisual/test_init.py +++ b/tests/components/airvisual/test_init.py @@ -99,7 +99,9 @@ async def test_migration_1_2(hass: HomeAssistant, mock_pyairvisual) -> None: } -async def test_migration_2_3(hass: HomeAssistant, mock_pyairvisual) -> None: +async def test_migration_2_3( + hass: HomeAssistant, mock_pyairvisual, device_registry: dr.DeviceRegistry +) -> None: """Test migrating from version 2 to 3.""" entry = MockConfigEntry( domain=DOMAIN, @@ -113,7 +115,6 @@ async def test_migration_2_3(hass: HomeAssistant, mock_pyairvisual) -> None: ) entry.add_to_hass(hass) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( name="192.168.1.100", config_entry_id=entry.entry_id, diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index 2214e5d07ab..8936fa3e282 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -14,11 +14,11 @@ from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK from tests.common import MockConfigEntry -async def test_unique_id_migrate(hass: HomeAssistant) -> None: +async def test_unique_id_migrate( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique id migration.""" - entity_registry = er.async_get(hass) - config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) config_entry.add_to_hass(hass) diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 3fb79c86e50..87aab24a3b1 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -25,9 +25,10 @@ async def test_unsupported_domain(hass: HomeAssistant) -> None: assert not msg["payload"]["endpoints"] -async def test_categorized_hidden_entities(hass: HomeAssistant) -> None: +async def test_categorized_hidden_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Discovery ignores hidden and categorized entities.""" - entity_registry = er.async_get(hass) request = get_new_request("Alexa.Discovery", "Discover") entity_entry1 = entity_registry.async_get_or_create( diff --git a/tests/components/apcupsd/test_binary_sensor.py b/tests/components/apcupsd/test_binary_sensor.py index 6ba9a09f837..033b1ff6b82 100644 --- a/tests/components/apcupsd/test_binary_sensor.py +++ b/tests/components/apcupsd/test_binary_sensor.py @@ -5,15 +5,16 @@ from homeassistant.helpers import entity_registry as er from . import MOCK_STATUS, async_init_integration -async def test_binary_sensor(hass: HomeAssistant) -> None: +async def test_binary_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of binary sensor.""" await async_init_integration(hass, status=MOCK_STATUS) - registry = er.async_get(hass) state = hass.states.get("binary_sensor.ups_online_status") assert state assert state.state == "on" - entry = registry.async_get("binary_sensor.ups_online_status") + entry = entity_registry.async_get("binary_sensor.ups_online_status") assert entry assert entry.unique_id == "XXXXXXXXXXXX_statflag" diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 8c29edabbc1..9bdcc89a9a3 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -42,19 +42,19 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No MOCK_STATUS, ), ) -async def test_device_entry(hass: HomeAssistant, status: OrderedDict) -> None: +async def test_device_entry( + hass: HomeAssistant, status: OrderedDict, device_registry: dr.DeviceRegistry +) -> None: """Test successful setup of device entries.""" await async_init_integration(hass, status=status) # Verify device info is properly set up. - device_entries = dr.async_get(hass) - if "SERIALNO" not in status: - assert len(device_entries.devices) == 0 + assert len(device_registry.devices) == 0 return - assert len(device_entries.devices) == 1 - entry = device_entries.async_get_device({(DOMAIN, status["SERIALNO"])}) + assert len(device_registry.devices) == 1 + entry = device_registry.async_get_device({(DOMAIN, status["SERIALNO"])}) assert entry is not None # Specify the mapping between field name and the expected fields in device entry. fields = { diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 1b09e107682..743b1f87847 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -19,16 +19,15 @@ from homeassistant.helpers import entity_registry as er from . import MOCK_STATUS, async_init_integration -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of sensor.""" await async_init_integration(hass, status=MOCK_STATUS) - registry = er.async_get(hass) # Test a representative string sensor. state = hass.states.get("sensor.ups_mode") assert state assert state.state == "Stand Alone" - entry = registry.async_get("sensor.ups_mode") + entry = entity_registry.async_get("sensor.ups_mode") assert entry assert entry.unique_id == "XXXXXXXXXXXX_upsmode" @@ -41,7 +40,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - entry = registry.async_get("sensor.ups_input_voltage") + entry = entity_registry.async_get("sensor.ups_input_voltage") assert entry assert entry.unique_id == "XXXXXXXXXXXX_linev" @@ -53,7 +52,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - entry = registry.async_get("sensor.ups_battery_voltage") + entry = entity_registry.async_get("sensor.ups_battery_voltage") assert entry assert entry.unique_id == "XXXXXXXXXXXX_battv" @@ -62,7 +61,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state assert state.state == "7" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.DAYS - entry = registry.async_get("sensor.ups_self_test_interval") + entry = entity_registry.async_get("sensor.ups_self_test_interval") assert entry assert entry.unique_id == "XXXXXXXXXXXX_stesti" @@ -72,7 +71,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "14.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.ups_load") + entry = entity_registry.async_get("sensor.ups_load") assert entry assert entry.unique_id == "XXXXXXXXXXXX_loadpct" @@ -82,24 +81,25 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "330" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - entry = registry.async_get("sensor.ups_nominal_output_power") + entry = entity_registry.async_get("sensor.ups_nominal_output_power") assert entry assert entry.unique_id == "XXXXXXXXXXXX_nompower" -async def test_sensor_disabled(hass: HomeAssistant) -> None: +async def test_sensor_disabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test sensor disabled by default.""" await async_init_integration(hass) - registry = er.async_get(hass) # Test a representative integration-disabled sensor. - entry = registry.async_get("sensor.ups_model") + entry = entity_registry.async_get("sensor.ups_model") assert entry.disabled assert entry.unique_id == "XXXXXXXXXXXX_model" assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity. - updated_entry = registry.async_update_entity( + updated_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 090c1034e4e..9e70e65e0a8 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -102,10 +102,10 @@ async def test_select_entity_registering_device( hass: HomeAssistant, init_select: ConfigEntry, pipeline_data: PipelineData, + device_registry: dr.DeviceRegistry, ) -> None: """Test entity registering as an assist device.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device(identifiers={("test", "test")}) + device = device_registry.async_get_device(identifiers={("test", "test")}) assert device is not None # Test device is registered diff --git a/tests/components/atag/test_climate.py b/tests/components/atag/test_climate.py index 485ad0308bc..da5eefa589b 100644 --- a/tests/components/atag/test_climate.py +++ b/tests/components/atag/test_climate.py @@ -33,11 +33,12 @@ CLIMATE_ID = f"{Platform.CLIMATE}.{DOMAIN}" async def test_climate( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation and values of Atag climate device.""" await init_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(CLIMATE_ID) entity = entity_registry.async_get(CLIMATE_ID) diff --git a/tests/components/atag/test_sensors.py b/tests/components/atag/test_sensors.py index 58a687512e2..358fe27804a 100644 --- a/tests/components/atag/test_sensors.py +++ b/tests/components/atag/test_sensors.py @@ -9,14 +9,15 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation of ATAG sensors.""" entry = await init_integration(hass, aioclient_mock) - registry = er.async_get(hass) for item in SENSORS: sensor_id = "_".join(f"sensor.{item}".lower().split()) - assert registry.async_is_registered(sensor_id) - entry = registry.async_get(sensor_id) + assert entity_registry.async_is_registered(sensor_id) + entry = entity_registry.async_get(sensor_id) assert entry.unique_id in [f"{UID}-{v}" for v in SENSORS.values()] diff --git a/tests/components/atag/test_water_heater.py b/tests/components/atag/test_water_heater.py index 428ff890116..49425972d88 100644 --- a/tests/components/atag/test_water_heater.py +++ b/tests/components/atag/test_water_heater.py @@ -18,15 +18,16 @@ WATER_HEATER_ID = f"{Platform.WATER_HEATER}.{DOMAIN}" async def test_water_heater( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation of Atag water heater.""" with patch("pyatag.entities.DHW.status"): entry = await init_integration(hass, aioclient_mock) - registry = er.async_get(hass) - assert registry.async_is_registered(WATER_HEATER_ID) - entry = registry.async_get(WATER_HEATER_ID) + assert entity_registry.async_is_registered(WATER_HEATER_ID) + entry = entity_registry.async_get(WATER_HEATER_ID) assert entry.unique_id == f"{UID}-{Platform.WATER_HEATER}" diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 50cac4445ab..72352477b4a 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -293,13 +293,13 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF -async def test_doorbell_device_registry(hass: HomeAssistant) -> None: +async def test_doorbell_device_registry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test creation of a lock with doorsense and bridge ands up in the registry.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_august_with_devices(hass, [doorbell_one]) - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device(identifiers={("august", "tmt100")}) assert reg_device.model == "hydra1" assert reg_device.name == "tmt100 Name" diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 36a7f73f8a8..55bc44c6f27 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -19,7 +19,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .mocks import ( @@ -400,16 +399,17 @@ async def remove_device(ws_client, device_id, config_entry_id): async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) august_operative_lock = await _mock_operative_august_lock_detail(hass) config_entry = await _create_august_with_devices(hass, [august_operative_lock]) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] + entity = entity_registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) assert ( await remove_device( diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index d1e60951c20..bc2cd23b23d 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -35,13 +35,13 @@ from .mocks import ( from tests.common import async_fire_time_changed -async def test_lock_device_registry(hass: HomeAssistant) -> None: +async def test_lock_device_registry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test creation of a lock with doorsense and bridge ands up in the registry.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) await _create_august_with_devices(hass, [lock_one]) - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( identifiers={("august", "online_with_doorsense")} ) @@ -106,7 +106,9 @@ async def test_state_jammed(hass: HomeAssistant) -> None: assert lock_online_with_doorsense_name.state == STATE_JAMMED -async def test_one_lock_operation(hass: HomeAssistant) -> None: +async def test_one_lock_operation( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) await _create_august_with_devices(hass, [lock_one]) @@ -141,7 +143,6 @@ async def test_one_lock_operation(hass: HomeAssistant) -> None: assert lock_online_with_doorsense_name.state == STATE_LOCKED # No activity means it will be unavailable until the activity feed has data - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -152,7 +153,9 @@ async def test_one_lock_operation(hass: HomeAssistant) -> None: ) -async def test_one_lock_operation_pubnub_connected(hass: HomeAssistant) -> None: +async def test_one_lock_operation_pubnub_connected( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test lock and unlock operations are async when pubnub is connected.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) assert lock_one.pubsub_channel == "pubsub" @@ -217,7 +220,6 @@ async def test_one_lock_operation_pubnub_connected(hass: HomeAssistant) -> None: assert lock_online_with_doorsense_name.state == STATE_LOCKED # No activity means it will be unavailable until the activity feed has data - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index ae7d46dcb22..d71d22064fc 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -36,11 +36,12 @@ async def test_create_doorbell(hass: HomeAssistant) -> None: ) -async def test_create_doorbell_offline(hass: HomeAssistant) -> None: +async def test_create_doorbell_offline( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of a doorbell that is offline.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_august_with_devices(hass, [doorbell_one]) - entity_registry = er.async_get(hass) sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery") assert sensor_tmt100_name_battery.state == "81" @@ -62,11 +63,12 @@ async def test_create_doorbell_hardwired(hass: HomeAssistant) -> None: assert sensor_tmt100_name_battery is None -async def test_create_lock_with_linked_keypad(hass: HomeAssistant) -> None: +async def test_create_lock_with_linked_keypad( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of a lock with a linked keypad that both have a battery.""" lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json") await _create_august_with_devices(hass, [lock_one]) - entity_registry = er.async_get(hass) sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" @@ -92,11 +94,12 @@ async def test_create_lock_with_linked_keypad(hass: HomeAssistant) -> None: assert entry.unique_id == "5bc65c24e6ef2a263e1450a8_linked_keypad_battery" -async def test_create_lock_with_low_battery_linked_keypad(hass: HomeAssistant) -> None: +async def test_create_lock_with_low_battery_linked_keypad( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of a lock with a linked keypad that both have a battery.""" lock_one = await _mock_lock_from_fixture(hass, "get_lock.low_keypad_battery.json") await _create_august_with_devices(hass, [lock_one]) - entity_registry = er.async_get(hass) sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" @@ -135,7 +138,9 @@ async def test_create_lock_with_low_battery_linked_keypad(hass: HomeAssistant) - ) -async def test_lock_operator_bluetooth(hass: HomeAssistant) -> None: +async def test_lock_operator_bluetooth( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -144,7 +149,6 @@ async def test_lock_operator_bluetooth(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -160,7 +164,9 @@ async def test_lock_operator_bluetooth(hass: HomeAssistant) -> None: assert state.attributes["method"] == "mobile" -async def test_lock_operator_keypad(hass: HomeAssistant) -> None: +async def test_lock_operator_keypad( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -169,7 +175,6 @@ async def test_lock_operator_keypad(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -185,14 +190,15 @@ async def test_lock_operator_keypad(hass: HomeAssistant) -> None: assert state.attributes["method"] == "keypad" -async def test_lock_operator_remote(hass: HomeAssistant) -> None: +async def test_lock_operator_remote( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -208,7 +214,9 @@ async def test_lock_operator_remote(hass: HomeAssistant) -> None: assert state.attributes["method"] == "remote" -async def test_lock_operator_manual(hass: HomeAssistant) -> None: +async def test_lock_operator_manual( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -217,7 +225,6 @@ async def test_lock_operator_manual(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -232,7 +239,9 @@ async def test_lock_operator_manual(hass: HomeAssistant) -> None: assert state.attributes["method"] == "manual" -async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: +async def test_lock_operator_autorelock( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -241,7 +250,6 @@ async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -257,7 +265,9 @@ async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: assert state.attributes["method"] == "autorelock" -async def test_unlock_operator_manual(hass: HomeAssistant) -> None: +async def test_unlock_operator_manual( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock manually.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -266,7 +276,6 @@ async def test_unlock_operator_manual(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -282,7 +291,9 @@ async def test_unlock_operator_manual(hass: HomeAssistant) -> None: assert state.attributes["method"] == "manual" -async def test_unlock_operator_tag(hass: HomeAssistant) -> None: +async def test_unlock_operator_tag( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with a tag.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -291,7 +302,6 @@ async def test_unlock_operator_tag(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) diff --git a/tests/components/awair/test_init.py b/tests/components/awair/test_init.py index 00a5a422a4e..f3a4bb636e6 100644 --- a/tests/components/awair/test_init.py +++ b/tests/components/awair/test_init.py @@ -9,14 +9,13 @@ from .const import LOCAL_CONFIG, LOCAL_UNIQUE_ID async def test_local_awair_sensors( - hass: HomeAssistant, local_devices, local_data + hass: HomeAssistant, local_devices, local_data, device_registry: dr.DeviceRegistry ) -> None: """Test expected sensors on a local Awair.""" fixtures = [local_devices, local_data] entry = await setup_awair(hass, fixtures, LOCAL_UNIQUE_ID, LOCAL_CONFIG) - dev_reg = dr.async_get(hass) - device_entry = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device_entry = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] assert device_entry.name == "Mock Title" @@ -24,5 +23,5 @@ async def test_local_awair_sensors( hass.config_entries.async_update_entry(entry, title="Hello World") await hass.async_block_till_done() - device_entry = dev_reg.async_get(device_entry.id) + device_entry = device_registry.async_get(device_entry.id) assert device_entry.name == "Hello World" diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index 24bbb40d9cf..849ac59a22f 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -65,17 +65,20 @@ def assert_expected_properties( async def test_awair_gen1_sensors( - hass: HomeAssistant, user, cloud_devices, gen1_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + gen1_data, ) -> None: """Test expected sensors on a 1st gen Awair.""" fixtures = [user, cloud_devices, gen1_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", @@ -84,7 +87,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_temperature", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_TEMP].unique_id_tag}", "21.8", @@ -93,7 +96,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_humidity", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_HUMID].unique_id_tag}", "41.59", @@ -102,7 +105,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_carbon_dioxide", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_CO2].unique_id_tag}", "654.0", @@ -114,7 +117,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_vocs", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_VOC].unique_id_tag}", "366", @@ -126,7 +129,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_pm2_5", # gen1 unique_id should be awair_12345-DUST, which matches old integration behavior f"{AWAIR_UUID}_DUST", @@ -139,7 +142,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_pm10", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM10].unique_id_tag}", "14.3", @@ -159,17 +162,20 @@ async def test_awair_gen1_sensors( async def test_awair_gen2_sensors( - hass: HomeAssistant, user, cloud_devices, gen2_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + gen2_data, ) -> None: """Test expected sensors on a 2nd gen Awair.""" fixtures = [user, cloud_devices, gen2_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "97", @@ -178,7 +184,7 @@ async def test_awair_gen2_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_pm2_5", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "2.0", @@ -194,17 +200,16 @@ async def test_awair_gen2_sensors( async def test_local_awair_sensors( - hass: HomeAssistant, local_devices, local_data + hass: HomeAssistant, entity_registry: er.EntityRegistry, local_devices, local_data ) -> None: """Test expected sensors on a local Awair.""" fixtures = [local_devices, local_data] await setup_awair(hass, fixtures, LOCAL_UNIQUE_ID, LOCAL_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.mock_title_score", f"{local_devices['device_uuid']}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "94", @@ -213,17 +218,20 @@ async def test_local_awair_sensors( async def test_awair_mint_sensors( - hass: HomeAssistant, user, cloud_devices, mint_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + mint_data, ) -> None: """Test expected sensors on an Awair mint.""" fixtures = [user, cloud_devices, mint_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "98", @@ -232,7 +240,7 @@ async def test_awair_mint_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_pm2_5", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "1.0", @@ -244,7 +252,7 @@ async def test_awair_mint_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_illuminance", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "441.7", @@ -256,17 +264,20 @@ async def test_awair_mint_sensors( async def test_awair_glow_sensors( - hass: HomeAssistant, user, cloud_devices, glow_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + glow_data, ) -> None: """Test expected sensors on an Awair glow.""" fixtures = [user, cloud_devices, glow_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "93", @@ -278,17 +289,20 @@ async def test_awair_glow_sensors( async def test_awair_omni_sensors( - hass: HomeAssistant, user, cloud_devices, omni_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + omni_data, ) -> None: """Test expected sensors on an Awair omni.""" fixtures = [user, cloud_devices, omni_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "99", @@ -297,7 +311,7 @@ async def test_awair_omni_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_sound_level", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SPL_A].unique_id_tag}", "47.0", @@ -306,7 +320,7 @@ async def test_awair_omni_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_illuminance", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "804.9", @@ -335,17 +349,21 @@ async def test_awair_offline( async def test_awair_unavailable( - hass: HomeAssistant, user, cloud_devices, gen1_data, awair_offline + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + gen1_data, + awair_offline, ) -> None: """Test expected behavior when an Awair becomes offline later.""" fixtures = [user, cloud_devices, gen1_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", @@ -356,7 +374,7 @@ async def test_awair_unavailable( await async_update_entity(hass, "sensor.living_room_score") assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", STATE_UNAVAILABLE, diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index ff7ff343a06..bc5bd13c284 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -41,7 +41,11 @@ def hass_mock_forward_entry_setup(hass): async def test_device_setup( - hass: HomeAssistant, forward_entry_setup, config, setup_config_entry + hass: HomeAssistant, + forward_entry_setup, + config, + setup_config_entry, + device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" device = hass.data[AXIS_DOMAIN][setup_config_entry.entry_id] @@ -62,7 +66,6 @@ async def test_device_setup( assert device.name == config[CONF_NAME] assert device.unique_id == FORMATTED_MAC - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(AXIS_DOMAIN, device.unique_id)} ) From 527a3dba9cd3eebd59257f6dd8be0e2ee95c630d Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Thu, 9 Nov 2023 14:41:25 -0800 Subject: [PATCH 364/982] Add script_mode parameter to custom intent scripts (#102203) * Add script_mode parameter to custom intent scripts * Reuse CONF_MODE from the script component --- homeassistant/components/intent_script/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index d5bec0573b8..d184dad47c9 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -6,6 +6,7 @@ from typing import Any, TypedDict import voluptuous as vol +from homeassistant.components.script import CONF_MODE from homeassistant.const import CONF_TYPE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( @@ -43,6 +44,9 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional( CONF_ASYNC_ACTION, default=DEFAULT_CONF_ASYNC_ACTION ): cv.boolean, + vol.Optional(CONF_MODE, default=script.DEFAULT_SCRIPT_MODE): vol.In( + script.SCRIPT_MODE_CHOICES + ), vol.Optional(CONF_CARD): { vol.Optional(CONF_TYPE, default="simple"): cv.string, vol.Required(CONF_TITLE): cv.template, @@ -87,8 +91,13 @@ def async_load_intents(hass: HomeAssistant, intents: dict[str, ConfigType]) -> N for intent_type, conf in intents.items(): if CONF_ACTION in conf: + script_mode: str = conf.get(CONF_MODE, script.DEFAULT_SCRIPT_MODE) conf[CONF_ACTION] = script.Script( - hass, conf[CONF_ACTION], f"Intent Script {intent_type}", DOMAIN + hass, + conf[CONF_ACTION], + f"Intent Script {intent_type}", + DOMAIN, + script_mode=script_mode, ) intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) From 64e8c995e6bad49ffed35ea7e7ac836f3441df5b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 10 Nov 2023 06:11:28 +0000 Subject: [PATCH 365/982] Bump pytrydan to 0.4.0 (#103721) Bump pytrydan --- homeassistant/components/v2c/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index d1a65e5f63d..ce0e9d7b847 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/v2c", "iot_class": "local_polling", - "requirements": ["pytrydan==0.3.0"] + "requirements": ["pytrydan==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index daf4947ad2f..8df3b1e3f33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2229,7 +2229,7 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.8 # homeassistant.components.v2c -pytrydan==0.3.0 +pytrydan==0.4.0 # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8692b87492c..7c71bc85997 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1664,7 +1664,7 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.8 # homeassistant.components.v2c -pytrydan==0.3.0 +pytrydan==0.4.0 # homeassistant.components.usb pyudev==0.23.2 From a06fabfbc6d174d6ddee5fe6fc869305ef41ebb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Nov 2023 00:30:31 -0600 Subject: [PATCH 366/982] Bump aioesphomeapi to 18.3.0 (#103730) changelog: https://github.com/esphome/aioesphomeapi/compare/v18.2.7...v18.3.0 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index ff9ac3da6e1..9b375610a99 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.2.7", + "aioesphomeapi==18.3.0", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 8df3b1e3f33..77714d6475e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.7 +aioesphomeapi==18.3.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c71bc85997..1c6f1565e83 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.7 +aioesphomeapi==18.3.0 # homeassistant.components.flo aioflo==2021.11.0 From 2f26096469f71fb53826cf766d4bdcd9bb44ec19 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 10 Nov 2023 09:04:33 +0100 Subject: [PATCH 367/982] Update frontend to 20231030.2 (#103706) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6fffc0e8acd..469deab23e1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231030.1"] + "requirements": ["home-assistant-frontend==20231030.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6ed0bf77abc..658df13bece 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.1 +home-assistant-frontend==20231030.2 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 77714d6475e..446e72d76d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1013,7 +1013,7 @@ hole==0.8.0 holidays==0.35 # homeassistant.components.frontend -home-assistant-frontend==20231030.1 +home-assistant-frontend==20231030.2 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c6f1565e83..226636ef538 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -802,7 +802,7 @@ hole==0.8.0 holidays==0.35 # homeassistant.components.frontend -home-assistant-frontend==20231030.1 +home-assistant-frontend==20231030.2 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 9f6eef7cca8861860908b01ec49cc3be4a250188 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 10 Nov 2023 09:27:33 +0100 Subject: [PATCH 368/982] Fix Reolink DHCP IP update (#103654) --- .../components/reolink/config_flow.py | 4 ++- tests/components/reolink/conftest.py | 16 ++++++++--- tests/components/reolink/test_config_flow.py | 27 +++++++++++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 59fbdc22747..a27c84b9593 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -113,7 +113,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): raise AbortFlow("already_configured") # check if the camera is reachable at the new IP - host = ReolinkHost(self.hass, existing_entry.data, existing_entry.options) + new_config = dict(existing_entry.data) + new_config[CONF_HOST] = discovery_info.ip + host = ReolinkHost(self.hass, new_config, existing_entry.options) try: await host.api.get_state("GetLocalLink") await host.api.logout() diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 25719c4cff7..3efc1e481df 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -34,8 +34,10 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None]: - """Mock reolink connection.""" +def reolink_connect_class( + mock_get_source_ip: None, +) -> Generator[MagicMock, None, None]: + """Mock reolink connection and return both the host_mock and host_mock_class.""" with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, @@ -65,7 +67,15 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 - yield host_mock + yield host_mock_class + + +@pytest.fixture +def reolink_connect( + reolink_connect_class: MagicMock, +) -> Generator[MagicMock, None, None]: + """Mock reolink connection.""" + return reolink_connect_class.return_value @pytest.fixture diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 1a4bf999cce..9b449d4b851 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -2,7 +2,7 @@ from datetime import timedelta import json from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError @@ -12,6 +12,7 @@ from homeassistant.components import dhcp from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL, const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.exceptions import ReolinkWebhookException +from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -380,41 +381,47 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No @pytest.mark.parametrize( - ("last_update_success", "attr", "value", "expected"), + ("last_update_success", "attr", "value", "expected", "host_call_list"), [ ( False, None, None, TEST_HOST2, + [TEST_HOST, TEST_HOST2], ), ( True, None, None, TEST_HOST, + [TEST_HOST], ), ( False, "get_state", AsyncMock(side_effect=ReolinkError("Test error")), TEST_HOST, + [TEST_HOST, TEST_HOST2], ), ( False, "mac_address", "aa:aa:aa:aa:aa:aa", TEST_HOST, + [TEST_HOST, TEST_HOST2], ), ], ) async def test_dhcp_ip_update( hass: HomeAssistant, + reolink_connect_class: MagicMock, reolink_connect: MagicMock, last_update_success: bool, attr: str, value: Any, expected: str, + host_call_list: list[str], ) -> None: """Test dhcp discovery aborts if already configured where the IP is updated if appropriate.""" config_entry = MockConfigEntry( @@ -459,6 +466,22 @@ async def test_dhcp_ip_update( const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data ) + expected_calls = [] + for host in host_call_list: + expected_calls.append( + call( + host, + TEST_USERNAME, + TEST_PASSWORD, + port=TEST_PORT, + use_https=TEST_USE_HTTPS, + protocol=DEFAULT_PROTOCOL, + timeout=DEFAULT_TIMEOUT, + ) + ) + + assert reolink_connect_class.call_args_list == expected_calls + assert result["type"] is data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" From 70ad5ab3e4a822719633f4285c0bd6c47a320571 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 10 Nov 2023 09:32:19 +0100 Subject: [PATCH 369/982] Update helper tests to use device & entity registry fixtures (#103710) --- tests/helpers/test_collection.py | 10 +- tests/helpers/test_condition.py | 14 +- tests/helpers/test_device_registry.py | 22 ++- tests/helpers/test_entity_platform.py | 117 ++++++------ tests/helpers/test_entity_registry.py | 171 +++++++++--------- .../helpers/test_schema_config_entry_flow.py | 7 +- tests/helpers/test_script.py | 35 ++-- tests/helpers/test_template.py | 7 +- tests/test_config_entries.py | 11 +- 9 files changed, 209 insertions(+), 185 deletions(-) diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index 7969e02ab2f..a385ca8aeb6 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -293,7 +293,9 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: assert hass.states.get("test.mock_1") is None -async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: +async def test_entity_component_collection_abort( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test aborted entity adding is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) @@ -318,7 +320,6 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: collection.sync_entity_lifecycle( hass, "test", "test", ent_comp, coll, MockMockEntity ) - entity_registry = er.async_get(hass) entity_registry.async_get_or_create( "test", "test", @@ -360,7 +361,9 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: assert len(async_remove_calls) == 0 -async def test_entity_component_collection_entity_removed(hass: HomeAssistant) -> None: +async def test_entity_component_collection_entity_removed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity removal is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) @@ -385,7 +388,6 @@ async def test_entity_component_collection_entity_removed(hass: HomeAssistant) - collection.sync_entity_lifecycle( hass, "test", "test", ent_comp, coll, MockMockEntity ) - entity_registry = er.async_get(hass) entity_registry.async_get_or_create( "test", "test", "mock_id", suggested_object_id="mock_1" ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 2512f426f13..3b8217028cc 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1373,10 +1373,11 @@ async def test_state_attribute_boolean(hass: HomeAssistant) -> None: assert test(hass) -async def test_state_entity_registry_id(hass: HomeAssistant) -> None: +async def test_state_entity_registry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test with entity specified by entity registry id.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "switch", "hue", "1234", suggested_object_id="test" ) assert entry.entity_id == "switch.test" @@ -1715,10 +1716,11 @@ async def test_numeric_state_attribute(hass: HomeAssistant) -> None: assert not test(hass) -async def test_numeric_state_entity_registry_id(hass: HomeAssistant) -> None: +async def test_numeric_state_entity_registry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test with entity specified by entity registry id.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "sensor", "hue", "1234", suggested_object_id="test" ) assert entry.entity_id == "sensor.test" diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 89f4eb5e319..657d8871e66 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1326,7 +1326,9 @@ async def test_update_suggested_area( async def test_cleanup_device_registry( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test cleanup works.""" config_entry = MockConfigEntry(domain="hue") @@ -1349,13 +1351,12 @@ async def test_cleanup_device_registry( # Remove the config entry without triggering the normal cleanup hass.config_entries._entries.pop(ghost_config_entry.entry_id) - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create("light", "hue", "e1", device_id=d1.id) - ent_reg.async_get_or_create("light", "hue", "e2", device_id=d1.id) - ent_reg.async_get_or_create("light", "hue", "e3", device_id=d3.id) + entity_registry.async_get_or_create("light", "hue", "e1", device_id=d1.id) + entity_registry.async_get_or_create("light", "hue", "e2", device_id=d1.id) + entity_registry.async_get_or_create("light", "hue", "e3", device_id=d3.id) # Manual cleanup should detect the orphaned config entry - dr.async_cleanup(hass, device_registry, ent_reg) + dr.async_cleanup(hass, device_registry, entity_registry) assert device_registry.async_get_device(identifiers={("hue", "d1")}) is not None assert device_registry.async_get_device(identifiers={("hue", "d2")}) is not None @@ -1364,7 +1365,9 @@ async def test_cleanup_device_registry( async def test_cleanup_device_registry_removes_expired_orphaned_devices( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test cleanup removes expired orphaned devices.""" config_entry = MockConfigEntry(domain="hue") @@ -1384,8 +1387,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 3 - ent_reg = er.async_get(hass) - dr.async_cleanup(hass, device_registry, ent_reg) + dr.async_cleanup(hass, device_registry, entity_registry) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 3 @@ -1393,7 +1395,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 with patch("time.time", return_value=future_time): - dr.async_cleanup(hass, device_registry, ent_reg) + dr.async_cleanup(hass, device_registry, entity_registry) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 0 diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index af8fbf59049..57020268323 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -594,7 +594,9 @@ async def test_async_remove_with_platform_update_finishes(hass: HomeAssistant) - async def test_not_adding_duplicate_entities_with_unique_id( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for not adding duplicate entities. @@ -627,9 +629,8 @@ async def test_not_adding_duplicate_entities_with_unique_id( assert ent2.platform is None assert len(hass.states.async_entity_ids()) == 1 - registry = er.async_get(hass) # test the entity name was not updated - entry = registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique") + entry = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique") assert entry.original_name == "test1" @@ -759,7 +760,9 @@ async def test_registry_respect_entity_disabled(hass: HomeAssistant) -> None: async def test_unique_id_conflict_has_priority_over_disabled_entity( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that an entity that is not unique has priority over a disabled entity.""" component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -777,9 +780,8 @@ async def test_unique_id_conflict_has_priority_over_disabled_entity( assert "Platform test_domain does not generate unique IDs." in caplog.text assert entity1.registry_entry is not None assert entity2.registry_entry is None - registry = er.async_get(hass) # test the entity name was not updated - entry = registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique") + entry = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique") assert entry.original_name == "test1" @@ -1074,12 +1076,13 @@ async def test_entity_registry_updates_invalid_entity_id(hass: HomeAssistant) -> assert hass.states.get("diff_domain.world") is None -async def test_device_info_called(hass: HomeAssistant) -> None: +async def test_device_info_called( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test device info is forwarded correctly.""" - registry = dr.async_get(hass) config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) - via = registry.async_get_or_create( + via = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections=set(), identifiers={("hue", "via-id")}, @@ -1124,7 +1127,7 @@ async def test_device_info_called(hass: HomeAssistant) -> None: assert len(hass.states.async_entity_ids()) == 2 - device = registry.async_get_device(identifiers={("hue", "1234")}) + device = device_registry.async_get_device(identifiers={("hue", "1234")}) assert device is not None assert device.identifiers == {("hue", "1234")} assert device.configuration_url == "http://192.168.0.100/config" @@ -1139,12 +1142,13 @@ async def test_device_info_called(hass: HomeAssistant) -> None: assert device.via_device_id == via.id -async def test_device_info_not_overrides(hass: HomeAssistant) -> None: +async def test_device_info_not_overrides( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test device info is forwarded correctly.""" - registry = dr.async_get(hass) config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) - device = registry.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "abcd")}, manufacturer="test-manufacturer", @@ -1179,7 +1183,7 @@ async def test_device_info_not_overrides(hass: HomeAssistant) -> None: assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - device2 = registry.async_get_device( + device2 = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "abcd")} ) assert device2 is not None @@ -1189,13 +1193,14 @@ async def test_device_info_not_overrides(hass: HomeAssistant) -> None: async def test_device_info_homeassistant_url( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test device info with homeassistant URL.""" - registry = dr.async_get(hass) config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) - registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections=set(), identifiers={("mqtt", "via-id")}, @@ -1229,20 +1234,21 @@ async def test_device_info_homeassistant_url( assert len(hass.states.async_entity_ids()) == 1 - device = registry.async_get_device(identifiers={("mqtt", "1234")}) + device = device_registry.async_get_device(identifiers={("mqtt", "1234")}) assert device is not None assert device.identifiers == {("mqtt", "1234")} assert device.configuration_url == "homeassistant://config/mqtt" async def test_device_info_change_to_no_url( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test device info changes to no URL.""" - registry = dr.async_get(hass) config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) - registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections=set(), identifiers={("mqtt", "via-id")}, @@ -1277,13 +1283,15 @@ async def test_device_info_change_to_no_url( assert len(hass.states.async_entity_ids()) == 1 - device = registry.async_get_device(identifiers={("mqtt", "1234")}) + device = device_registry.async_get_device(identifiers={("mqtt", "1234")}) assert device is not None assert device.identifiers == {("mqtt", "1234")} assert device.configuration_url is None -async def test_entity_disabled_by_integration(hass: HomeAssistant) -> None: +async def test_entity_disabled_by_integration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity disabled by integration.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) await component.async_setup({}) @@ -1300,15 +1308,17 @@ async def test_entity_disabled_by_integration(hass: HomeAssistant) -> None: assert entity_disabled.hass is None assert entity_disabled.platform is None - registry = er.async_get(hass) - - entry_default = registry.async_get_or_create(DOMAIN, DOMAIN, "default") + entry_default = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default.disabled_by is None - entry_disabled = registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") + entry_disabled = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") assert entry_disabled.disabled_by is er.RegistryEntryDisabler.INTEGRATION -async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: +async def test_entity_disabled_by_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test entity disabled by device.""" connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} @@ -1328,7 +1338,6 @@ async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: hass, platform_name=config_entry.domain, platform=platform ) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections=connections, @@ -1341,13 +1350,13 @@ async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: assert entity_disabled.hass is None assert entity_disabled.platform is None - registry = er.async_get(hass) - - entry_disabled = registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") + entry_disabled = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") assert entry_disabled.disabled_by is er.RegistryEntryDisabler.DEVICE -async def test_entity_hidden_by_integration(hass: HomeAssistant) -> None: +async def test_entity_hidden_by_integration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity hidden by integration.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) await component.async_setup({}) @@ -1359,15 +1368,15 @@ async def test_entity_hidden_by_integration(hass: HomeAssistant) -> None: await component.async_add_entities([entity_default, entity_hidden]) - registry = er.async_get(hass) - - entry_default = registry.async_get_or_create(DOMAIN, DOMAIN, "default") + entry_default = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default.hidden_by is None - entry_hidden = registry.async_get_or_create(DOMAIN, DOMAIN, "hidden") + entry_hidden = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "hidden") assert entry_hidden.hidden_by is er.RegistryEntryHider.INTEGRATION -async def test_entity_info_added_to_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_info_added_to_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity info is written to entity registry.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) await component.async_setup({}) @@ -1387,9 +1396,7 @@ async def test_entity_info_added_to_entity_registry(hass: HomeAssistant) -> None await component.async_add_entities([entity_default]) - registry = er.async_get(hass) - - entry_default = registry.async_get_or_create(DOMAIN, DOMAIN, "default") + entry_default = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default == er.RegistryEntry( "test_domain.best_name", "default", @@ -1729,12 +1736,12 @@ class SlowEntity(MockEntity): ) async def test_entity_name_influences_entity_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, has_entity_name: bool, entity_name: str | None, expected_entity_id: str, ) -> None: """Test entity_id is influenced by entity name.""" - registry = er.async_get(hass) async def async_setup_entry(hass, config_entry, async_add_entities): """Mock setup entry method.""" @@ -1765,7 +1772,7 @@ async def test_entity_name_influences_entity_id( await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 - assert registry.async_get(expected_entity_id) is not None + assert entity_registry.async_get(expected_entity_id) is not None @pytest.mark.parametrize( @@ -1780,6 +1787,7 @@ async def test_entity_name_influences_entity_id( ) async def test_translated_entity_name_influences_entity_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, language: str, has_entity_name: bool, expected_entity_id: str, @@ -1800,8 +1808,6 @@ async def test_translated_entity_name_influences_entity_id( """Initialize.""" self._attr_has_entity_name = has_entity_name - registry = er.async_get(hass) - translations = { "en": {"component.test.entity.test_domain.test.name": "English name"}, "sv": {"component.test.entity.test_domain.test.name": "Swedish name"}, @@ -1839,7 +1845,7 @@ async def test_translated_entity_name_influences_entity_id( await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 - assert registry.async_get(expected_entity_id) is not None + assert entity_registry.async_get(expected_entity_id) is not None @pytest.mark.parametrize( @@ -1860,6 +1866,7 @@ async def test_translated_entity_name_influences_entity_id( ) async def test_translated_device_class_name_influences_entity_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, language: str, has_entity_name: bool, device_class: str | None, @@ -1884,8 +1891,6 @@ async def test_translated_device_class_name_influences_entity_id( """Return True if an unnamed entity should be named by its device class.""" return self.device_class is not None - registry = er.async_get(hass) - translations = { "en": {"component.test_domain.entity_component.test_class.name": "English cls"}, "sv": {"component.test_domain.entity_component.test_class.name": "Swedish cls"}, @@ -1923,7 +1928,7 @@ async def test_translated_device_class_name_influences_entity_id( await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 - assert registry.async_get(expected_entity_id) is not None + assert entity_registry.async_get(expected_entity_id) is not None @pytest.mark.parametrize( @@ -1942,6 +1947,7 @@ async def test_translated_device_class_name_influences_entity_id( ) async def test_device_name_defaulting_config_entry( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, config_entry_title: str, entity_device_name: str, entity_device_default_name: str, @@ -1976,8 +1982,9 @@ async def test_device_name_defaulting_config_entry( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device(connections={(dr.CONNECTION_NETWORK_MAC, "1234")}) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "1234")} + ) assert device is not None assert device.name == expected_device_name @@ -2002,6 +2009,8 @@ async def test_device_name_defaulting_config_entry( ) async def test_device_type_error_checking( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, device_info: dict, number_of_entities: int, ) -> None: @@ -2027,8 +2036,6 @@ async def test_device_type_error_checking( assert await entity_platform.async_setup_entry(config_entry) - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 0 - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == number_of_entities + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == number_of_entities assert len(hass.states.async_all()) == number_of_entities diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 95558e9c73d..d01d7746253 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -866,27 +866,27 @@ async def test_disabled_by_config_entry_pref( assert entry2.disabled_by is er.RegistryEntryDisabler.USER -async def test_restore_states(hass: HomeAssistant) -> None: +async def test_restore_states( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test restoring states.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "1234", suggested_object_id="simple", ) # Should not be created - registry.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "5678", suggested_object_id="disabled", disabled_by=er.RegistryEntryDisabler.HASS, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "9012", @@ -921,9 +921,9 @@ async def test_restore_states(hass: HomeAssistant) -> None: "icon": "hass:original-icon", } - registry.async_remove("light.disabled") - registry.async_remove("light.simple") - registry.async_remove("light.all_info_set") + entity_registry.async_remove("light.disabled") + entity_registry.async_remove("light.simple") + entity_registry.async_remove("light.all_info_set") await hass.async_block_till_done() @@ -932,58 +932,58 @@ async def test_restore_states(hass: HomeAssistant) -> None: assert hass.states.get("light.all_info_set") is None -async def test_async_get_device_class_lookup(hass: HomeAssistant) -> None: +async def test_async_get_device_class_lookup( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test registry device class lookup.""" hass.state = CoreState.not_running - ent_reg = er.async_get(hass) - - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "binary_sensor", "light", "battery_charging", device_id="light_device_entry_id", original_device_class="battery_charging", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "light", "battery", device_id="light_device_entry_id", original_device_class="battery", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "light", "light", "demo", device_id="light_device_entry_id" ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "binary_sensor", "vacuum", "battery_charging", device_id="vacuum_device_entry_id", original_device_class="battery_charging", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "vacuum", "battery", device_id="vacuum_device_entry_id", original_device_class="battery", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "vacuum", "vacuum", "demo", device_id="vacuum_device_entry_id" ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "binary_sensor", "remote", "battery_charging", device_id="remote_device_entry_id", original_device_class="battery_charging", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "remote", "remote", "demo", device_id="remote_device_entry_id" ) - device_lookup = ent_reg.async_get_device_class_lookup( + device_lookup = entity_registry.async_get_device_class_lookup( {("binary_sensor", "battery_charging"), ("sensor", "battery")} ) @@ -1476,50 +1476,52 @@ def test_entity_registry_items() -> None: assert entities.get_entry(entry2.id) is None -async def test_disabled_by_str_not_allowed(hass: HomeAssistant) -> None: +async def test_disabled_by_str_not_allowed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we need to pass disabled by type.""" - reg = er.async_get(hass) - with pytest.raises(ValueError): - reg.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "1234", disabled_by=er.RegistryEntryDisabler.USER.value ) - entity_id = reg.async_get_or_create("light", "hue", "1234").entity_id + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id with pytest.raises(ValueError): - reg.async_update_entity( + entity_registry.async_update_entity( entity_id, disabled_by=er.RegistryEntryDisabler.USER.value ) -async def test_entity_category_str_not_allowed(hass: HomeAssistant) -> None: +async def test_entity_category_str_not_allowed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we need to pass entity category type.""" - reg = er.async_get(hass) - with pytest.raises(ValueError): - reg.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "1234", entity_category=EntityCategory.DIAGNOSTIC.value ) - entity_id = reg.async_get_or_create("light", "hue", "1234").entity_id + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id with pytest.raises(ValueError): - reg.async_update_entity( + entity_registry.async_update_entity( entity_id, entity_category=EntityCategory.DIAGNOSTIC.value ) -async def test_hidden_by_str_not_allowed(hass: HomeAssistant) -> None: +async def test_hidden_by_str_not_allowed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we need to pass hidden by type.""" - reg = er.async_get(hass) - with pytest.raises(ValueError): - reg.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "1234", hidden_by=er.RegistryEntryHider.USER.value ) - entity_id = reg.async_get_or_create("light", "hue", "1234").entity_id + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id with pytest.raises(ValueError): - reg.async_update_entity(entity_id, hidden_by=er.RegistryEntryHider.USER.value) + entity_registry.async_update_entity( + entity_id, hidden_by=er.RegistryEntryHider.USER.value + ) def test_migrate_entity_to_new_platform( @@ -1595,34 +1597,35 @@ def test_migrate_entity_to_new_platform( ) -async def test_restore_entity(hass, update_events, freezer): +async def test_restore_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry, update_events, freezer +): """Make sure entity registry id is stable and entity_id is reused if possible.""" - registry = er.async_get(hass) # We need the real entity registry for this test config_entry = MockConfigEntry(domain="light") - entry1 = registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) - entry2 = registry.async_get_or_create( + entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry ) - entry1 = registry.async_update_entity( + entry1 = entity_registry.async_update_entity( entry1.entity_id, new_entity_id="light.custom_1" ) - registry.async_remove(entry1.entity_id) - registry.async_remove(entry2.entity_id) - assert len(registry.entities) == 0 - assert len(registry.deleted_entities) == 2 + entity_registry.async_remove(entry1.entity_id) + entity_registry.async_remove(entry2.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 2 # Re-add entities - entry1_restored = registry.async_get_or_create( + entry1_restored = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) - entry2_restored = registry.async_get_or_create("light", "hue", "5678") + entry2_restored = entity_registry.async_get_or_create("light", "hue", "5678") - assert len(registry.entities) == 2 - assert len(registry.deleted_entities) == 0 + assert len(entity_registry.entities) == 2 + assert len(entity_registry.deleted_entities) == 0 assert entry1 != entry1_restored # entity_id is not restored assert attr.evolve(entry1, entity_id="light.hue_1234") == entry1_restored @@ -1631,39 +1634,39 @@ async def test_restore_entity(hass, update_events, freezer): assert attr.evolve(entry2, config_entry_id=None) == entry2_restored # Remove two of the entities again, then bump time - registry.async_remove(entry1_restored.entity_id) - registry.async_remove(entry2.entity_id) - assert len(registry.entities) == 0 - assert len(registry.deleted_entities) == 2 + entity_registry.async_remove(entry1_restored.entity_id) + entity_registry.async_remove(entry2.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 2 freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1)) async_fire_time_changed(hass) await hass.async_block_till_done() # Re-add two entities, expect to get a new id after the purge for entity w/o config entry - entry1_restored = registry.async_get_or_create( + entry1_restored = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) - entry2_restored = registry.async_get_or_create("light", "hue", "5678") - assert len(registry.entities) == 2 - assert len(registry.deleted_entities) == 0 + entry2_restored = entity_registry.async_get_or_create("light", "hue", "5678") + assert len(entity_registry.entities) == 2 + assert len(entity_registry.deleted_entities) == 0 assert entry1.id == entry1_restored.id assert entry2.id != entry2_restored.id # Remove the first entity, then its config entry, finally bump time - registry.async_remove(entry1_restored.entity_id) - assert len(registry.entities) == 1 - assert len(registry.deleted_entities) == 1 - registry.async_clear_config_entry(config_entry.entry_id) + entity_registry.async_remove(entry1_restored.entity_id) + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 1 + entity_registry.async_clear_config_entry(config_entry.entry_id) freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1)) async_fire_time_changed(hass) await hass.async_block_till_done() # Re-add the entity, expect to get a new id after the purge - entry1_restored = registry.async_get_or_create( + entry1_restored = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) - assert len(registry.entities) == 2 - assert len(registry.deleted_entities) == 0 + assert len(entity_registry.entities) == 2 + assert len(entity_registry.deleted_entities) == 0 assert entry1.id != entry1_restored.id # Check the events @@ -1687,18 +1690,19 @@ async def test_restore_entity(hass, update_events, freezer): assert update_events[12] == {"action": "create", "entity_id": "light.hue_1234"} -async def test_async_migrate_entry_delete_self(hass): +async def test_async_migrate_entry_delete_self( + hass: HomeAssistant, entity_registry: er.EntityRegistry +): """Test async_migrate_entry.""" - registry = er.async_get(hass) config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") - entry1 = registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) - entry2 = registry.async_get_or_create( + entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" ) - entry3 = registry.async_get_or_create( + entry3 = entity_registry.async_get_or_create( "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" ) @@ -1706,7 +1710,7 @@ async def test_async_migrate_entry_delete_self(hass): def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: entries.add(entity_entry.entity_id) if entity_entry == entry1: - registry.async_remove(entry1.entity_id) + entity_registry.async_remove(entry1.entity_id) return None if entity_entry == entry2: return {"original_name": "Entry 2 renamed"} @@ -1715,24 +1719,25 @@ async def test_async_migrate_entry_delete_self(hass): entries = set() await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) assert entries == {entry1.entity_id, entry2.entity_id} - assert not registry.async_is_registered(entry1.entity_id) - entry2 = registry.async_get(entry2.entity_id) + assert not entity_registry.async_is_registered(entry1.entity_id) + entry2 = entity_registry.async_get(entry2.entity_id) assert entry2.original_name == "Entry 2 renamed" - assert registry.async_get(entry3.entity_id) is entry3 + assert entity_registry.async_get(entry3.entity_id) is entry3 -async def test_async_migrate_entry_delete_other(hass): +async def test_async_migrate_entry_delete_other( + hass: HomeAssistant, entity_registry: er.EntityRegistry +): """Test async_migrate_entry.""" - registry = er.async_get(hass) config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") - entry1 = registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) - entry2 = registry.async_get_or_create( + entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" ) @@ -1740,7 +1745,7 @@ async def test_async_migrate_entry_delete_other(hass): def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: entries.add(entity_entry.entity_id) if entity_entry == entry1: - registry.async_remove(entry2.entity_id) + entity_registry.async_remove(entry2.entity_id) return None if entity_entry == entry2: # We should not get here @@ -1750,4 +1755,4 @@ async def test_async_migrate_entry_delete_other(hass): entries = set() await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) assert entries == {entry1.entity_id} - assert not registry.async_is_registered(entry2.entity_id) + assert not entity_registry.async_is_registered(entry2.entity_id) diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 7954b63b241..b069f0cb8f5 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -78,9 +78,8 @@ def manager_fixture(): return mgr -async def test_name(hass: HomeAssistant) -> None: +async def test_name(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the config flow name is copied from registry entry, with fallback to state.""" - registry = er.async_get(hass) entity_id = "switch.ceiling" # No entry or state, use Object ID @@ -92,7 +91,7 @@ async def test_name(hass: HomeAssistant) -> None: # Entity registered, use original name from registry entry hass.states.async_remove(entity_id) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "switch", "test", "unique", @@ -105,7 +104,7 @@ async def test_name(hass: HomeAssistant) -> None: assert wrapped_entity_config_entry_title(hass, entry.id) == "Original Name" # Entity has customized name - registry.async_update_entity("switch.ceiling", name="Custom Name") + entity_registry.async_update_entity("switch.ceiling", name="Custom Name") assert wrapped_entity_config_entry_title(hass, entity_id) == "Custom Name" assert wrapped_entity_config_entry_title(hass, entry.id) == "Custom Name" diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 6c327345881..7e655a69c0a 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1788,11 +1788,12 @@ async def test_shorthand_template_condition( async def test_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions which validate late in a script.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -2385,11 +2386,12 @@ async def test_repeat_conditional( async def test_repeat_until_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions in repeat until conditions which validate late.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -2447,11 +2449,12 @@ async def test_repeat_until_condition_validation( async def test_repeat_while_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions in repeat while conditions which validate late.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -2868,11 +2871,12 @@ async def test_choose( async def test_choose_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions in choose actions which validate late.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -3112,11 +3116,12 @@ async def test_if_disabled( async def test_if_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions in if actions which validate late.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 5f7ef594909..79358ec588d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -4080,9 +4080,10 @@ def test_state_with_unit(hass: HomeAssistant) -> None: assert tpl.async_render() == "" -def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: +def test_state_with_unit_and_rounding( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test formatting the state rounded and with unit.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get_or_create( "sensor", "test", "very_unique", suggested_object_id="test" ) @@ -4153,6 +4154,7 @@ def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: ) def test_state_with_unit_and_rounding_options( hass: HomeAssistant, + entity_registry: er.EntityRegistry, rounded: str, with_unit: str, output1_1, @@ -4161,7 +4163,6 @@ def test_state_with_unit_and_rounding_options( output2_2, ) -> None: """Test formatting the state rounded and with unit.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get_or_create( "sensor", "test", "very_unique", suggested_object_id="test" ) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index eb771b7e6a6..a3c052971e3 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -269,7 +269,9 @@ async def test_call_async_migrate_entry_failure_not_supported( async def test_remove_entry( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + entity_registry: er.EntityRegistry, ) -> None: """Test that we can remove an entry.""" @@ -335,9 +337,8 @@ async def test_remove_entry( assert len(hass.states.async_all()) == 1 # Check entity got added to entity registry - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 1 - entity_entry = list(ent_reg.entities.values())[0] + assert len(entity_registry.entities) == 1 + entity_entry = list(entity_registry.entities.values())[0] assert entity_entry.config_entry_id == entry.entry_id # Remove entry @@ -358,7 +359,7 @@ async def test_remove_entry( assert len(hass.states.async_all()) == 0 # Check that entity registry entry has been removed - entity_entry_list = list(ent_reg.entities.values()) + entity_entry_list = list(entity_registry.entities.values()) assert not entity_entry_list From 10e7622e3817de6a392486256141dcbb164e7937 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 10 Nov 2023 02:20:58 -0800 Subject: [PATCH 370/982] Address flume post merge review comments (#102807) Co-authored-by: Martin Hjelmare --- homeassistant/components/flume/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 9a96233e6a9..a5911af3c8f 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.selector import ConfigEntrySelector @@ -88,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_setup_service(hass) + setup_service(hass) return True @@ -105,10 +106,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_setup_service(hass: HomeAssistant) -> None: +def setup_service(hass: HomeAssistant) -> None: """Add the services for the flume integration.""" - async def list_notifications(call: ServiceCall) -> ServiceResponse: + @callback + def list_notifications(call: ServiceCall) -> ServiceResponse: """Return the user notifications.""" entry_id: str = call.data[CONF_CONFIG_ENTRY] entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) From dda40c10d45a3e0b8cfff7041afcdf07b5d2804c Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 10 Nov 2023 11:31:14 +0100 Subject: [PATCH 371/982] Add myself to vicare codeowners (#103738) * Revert "Remove myself from vicare codeowners (#90755)" This reverts commit 21a873f0af9e72f013428fd6b58a2980d2e0acfe. * Apply suggestions from code review --- CODEOWNERS | 2 ++ homeassistant/components/vicare/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3c48cf66311..1afdf61ac84 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1389,6 +1389,8 @@ build.json @home-assistant/supervisor /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey +/homeassistant/components/vicare/ @CFenner +/tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW /homeassistant/components/vivotek/ @HarlemSquirrel diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index e8bc4178073..d71ccdbb12c 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -1,7 +1,7 @@ { "domain": "vicare", "name": "Viessmann ViCare", - "codeowners": [], + "codeowners": ["@CFenner"], "config_flow": true, "dhcp": [ { From 93f63c54725b379d757efe965395e966ef9eebd4 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 10 Nov 2023 12:28:32 +0000 Subject: [PATCH 372/982] Add number to V2C (#103681) --- .coveragerc | 1 + homeassistant/components/v2c/__init__.py | 2 +- homeassistant/components/v2c/number.py | 93 +++++++++++++++++++++++ homeassistant/components/v2c/strings.json | 5 ++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/v2c/number.py diff --git a/.coveragerc b/.coveragerc index 0a7e8cd7489..7ea4a1f5501 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1432,6 +1432,7 @@ omit = homeassistant/components/v2c/__init__.py homeassistant/components/v2c/coordinator.py homeassistant/components/v2c/entity.py + homeassistant/components/v2c/number.py homeassistant/components/v2c/sensor.py homeassistant/components/v2c/switch.py homeassistant/components/velbus/__init__.py diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index c1b22b5735d..97978a9ebc2 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers.httpx_client import get_async_client from .const import DOMAIN from .coordinator import V2CUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py new file mode 100644 index 00000000000..843dbbdfa65 --- /dev/null +++ b/homeassistant/components/v2c/number.py @@ -0,0 +1,93 @@ +"""Number platform for V2C settings.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from pytrydan import Trydan, TrydanData + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator +from .entity import V2CBaseEntity + +MIN_INTENSITY = 6 +MAX_INTENSITY = 32 + + +@dataclass +class V2CSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TrydanData], int] + update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]] + + +@dataclass +class V2CSettingsNumberEntityDescription( + NumberEntityDescription, V2CSettingsRequiredKeysMixin +): + """Describes V2C EVSE number entity.""" + + +TRYDAN_NUMBER_SETTINGS = ( + V2CSettingsNumberEntityDescription( + key="intensity", + translation_key="intensity", + device_class=NumberDeviceClass.CURRENT, + native_min_value=MIN_INTENSITY, + native_max_value=MAX_INTENSITY, + value_fn=lambda evse_data: evse_data.intensity, + update_fn=lambda evse, value: evse.intensity(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C Trydan number platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[NumberEntity] = [ + V2CSettingsNumberEntity(coordinator, description, config_entry.entry_id) + for description in TRYDAN_NUMBER_SETTINGS + ] + async_add_entities(entities) + + +class V2CSettingsNumberEntity(V2CBaseEntity, NumberEntity): + """Representation of V2C EVSE settings number entity.""" + + entity_description: V2CSettingsNumberEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: V2CSettingsNumberEntityDescription, + entry_id: str, + ) -> None: + """Initialize the V2C number entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the state of the setting entity.""" + return self.entity_description.value_fn(self.data) + + async def async_set_native_value(self, value: float) -> None: + """Update the setting.""" + await self.entity_description.update_fn(self.coordinator.evse, int(value)) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index fb30ea826d7..5108b89a58a 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -16,6 +16,11 @@ } }, "entity": { + "number": { + "intensity": { + "name": "Intensity" + } + }, "sensor": { "charge_power": { "name": "Charge power" From 3666af0b10dd32bd28bc6e0649099af392a2d440 Mon Sep 17 00:00:00 2001 From: Quentame Date: Fri, 10 Nov 2023 13:51:35 +0100 Subject: [PATCH 373/982] Fix Freebox flaky tests (#103745) --- tests/components/freebox/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 5d1b6fab0c8..8a6590d1105 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -24,7 +24,9 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) def mock_path(): """Mock path lib.""" - with patch("homeassistant.components.freebox.router.Path"): + with patch("homeassistant.components.freebox.router.Path"), patch( + "homeassistant.components.freebox.router.os.makedirs" + ): yield From 82ce5e56b5e610ced30838e805b0bf7b49a1740e Mon Sep 17 00:00:00 2001 From: Jean-Marie White Date: Sat, 11 Nov 2023 02:25:25 +1300 Subject: [PATCH 374/982] Fix DST handling in TOD (#84931) Co-authored-by: J. Nick Koston --- homeassistant/components/tod/binary_sensor.py | 23 ++- tests/components/tod/test_binary_sensor.py | 195 +++++++++++++++++- 2 files changed, 208 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index f72aa742f56..c3f2c75e07b 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -226,6 +226,21 @@ class TodSensor(BinarySensorEntity): self._time_after += self._after_offset self._time_before += self._before_offset + def _add_one_dst_aware_day(self, a_date: datetime, target_time: time) -> datetime: + """Add 24 hours (1 day) but account for DST.""" + tentative_new_date = a_date + timedelta(days=1) + tentative_new_date = dt_util.as_local(tentative_new_date) + tentative_new_date = tentative_new_date.replace( + hour=target_time.hour, minute=target_time.minute + ) + # The following call addresses missing time during DST jumps + return dt_util.find_next_time_expression_time( + tentative_new_date, + dt_util.parse_time_expression("*", 0, 59), + dt_util.parse_time_expression("*", 0, 59), + dt_util.parse_time_expression("*", 0, 23), + ) + def _turn_to_next_day(self) -> None: """Turn to to the next day.""" if TYPE_CHECKING: @@ -238,7 +253,9 @@ class TodSensor(BinarySensorEntity): self._time_after += self._after_offset else: # Offset is already there - self._time_after += timedelta(days=1) + self._time_after = self._add_one_dst_aware_day( + self._time_after, self._after + ) if _is_sun_event(self._before): self._time_before = get_astral_event_next( @@ -247,7 +264,9 @@ class TodSensor(BinarySensorEntity): self._time_before += self._before_offset else: # Offset is already there - self._time_before += timedelta(days=1) + self._time_before = self._add_one_dst_aware_day( + self._time_before, self._before + ) async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index c1823c23f8b..c7979b884d4 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -614,21 +614,62 @@ async def test_sun_offset( assert state.state == STATE_ON -async def test_dst( +async def test_dst1( hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: - """Test sun event with offset.""" + """Test DST when time falls in non-existent hour. Also check 48 hours later.""" hass.config.time_zone = "CET" dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) - test_time = datetime(2019, 3, 30, 3, 0, 0, tzinfo=hass_tz_info) + test_time1 = datetime(2019, 3, 30, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) config = { "binary_sensor": [ {"platform": "tod", "name": "Day", "after": "2:30", "before": "2:40"} ] } - # Test DST: + # Test DST #1: # after 2019-03-30 03:00 CET the next update should ge scheduled - # at 3:30 not 2:30 local time + # at 2:30am, but on 2019-03-31, that hour does not exist. That means + # the start/end will end up happning on the next available second (3am) + # Essentially, the ToD sensor never turns on that day. + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + assert state.state == STATE_OFF + + # But the following day, the sensor should resume it normal operation. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-04-01T02:30:00+02:00" + assert state.attributes["before"] == "2019-04-01T02:40:00+02:00" + assert state.attributes["next_update"] == "2019-04-01T02:30:00+02:00" + + assert state.state == STATE_OFF + + +async def test_dst2(hass, freezer, hass_tz_info): + """Test DST when there's a time switch in the East.""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time = datetime(2019, 3, 30, 5, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #2: + # after 2019-03-30 05:00 CET the next update should ge scheduled + # at 4:30+02 not 4:30+01 entity_id = "binary_sensor.day" freezer.move_to(test_time) await async_setup_component(hass, "binary_sensor", config) @@ -636,12 +677,150 @@ async def test_dst( await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes["after"] == "2019-03-31T03:30:00+02:00" - assert state.attributes["before"] == "2019-03-31T03:40:00+02:00" - assert state.attributes["next_update"] == "2019-03-31T03:30:00+02:00" + assert state.attributes["after"] == "2019-03-31T04:30:00+02:00" + assert state.attributes["before"] == "2019-03-31T04:40:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T04:30:00+02:00" assert state.state == STATE_OFF +async def test_dst3(hass, freezer, hass_tz_info): + """Test DST when there's a time switch forward in the West.""" + hass.config.time_zone = "US/Pacific" + dt_util.set_default_time_zone(dt_util.get_time_zone("US/Pacific")) + test_time = datetime( + 2023, 3, 11, 5, 0, 0, tzinfo=dt_util.get_time_zone("US/Pacific") + ) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #3: + # after 2023-03-11 05:00 Pacific the next update should ge scheduled + # at 4:30-07 not 4:30-08 + entity_id = "binary_sensor.day" + freezer.move_to(test_time) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2023-03-12T04:30:00-07:00" + assert state.attributes["before"] == "2023-03-12T04:40:00-07:00" + assert state.attributes["next_update"] == "2023-03-12T04:30:00-07:00" + assert state.state == STATE_OFF + + +async def test_dst4(hass, freezer, hass_tz_info): + """Test DST when there's a time switch backward in the West.""" + hass.config.time_zone = "US/Pacific" + dt_util.set_default_time_zone(dt_util.get_time_zone("US/Pacific")) + test_time = datetime( + 2023, 11, 4, 5, 0, 0, tzinfo=dt_util.get_time_zone("US/Pacific") + ) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #4: + # after 2023-11-04 05:00 Pacific the next update should ge scheduled + # at 4:30-08 not 4:30-07 + entity_id = "binary_sensor.day" + freezer.move_to(test_time) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2023-11-05T04:30:00-08:00" + assert state.attributes["before"] == "2023-11-05T04:40:00-08:00" + assert state.attributes["next_update"] == "2023-11-05T04:30:00-08:00" + assert state.state == STATE_OFF + + +async def test_dst5( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: + """Test DST when end time falls in non-existent hour (1:50am-2:10am).""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time1 = datetime(2019, 3, 30, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 1, 51, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "1:50", "before": "2:10"} + ] + } + # Test DST #5: + # Test the case where the end time does not exist (roll out to the next available time) + # First test before the sensor is turned on + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T01:50:00+01:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T01:50:00+01:00" + assert state.state == STATE_OFF + + # Seconds, test state when sensor is ON but end time has rolled out to next available time. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T01:50:00+01:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + + assert state.state == STATE_ON + + +async def test_dst6( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: + """Test DST when start time falls in non-existent hour (2:50am 3:10am).""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time1 = datetime(2019, 3, 30, 4, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 3, 1, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "2:50", "before": "3:10"} + ] + } + # Test DST #6: + # Test the case where the end time does not exist (roll out to the next available time) + # First test before the sensor is turned on + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:10:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + assert state.state == STATE_OFF + + # Seconds, test state when sensor is ON but end time has rolled out to next available time. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:10:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:10:00+02:00" + + assert state.state == STATE_ON + + @pytest.mark.freeze_time("2019-01-10 18:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_not_in_range( From 253e6188eb6ace4488dac69debd825779bd3c18c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Nov 2023 10:11:41 -0600 Subject: [PATCH 375/982] Bump dbus-fast to 2.14.0 (#103754) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 4ef14c60f40..89e6b350cad 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.14.0", - "dbus-fast==2.13.1" + "dbus-fast==2.14.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 658df13bece..d9b0418b77e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -18,7 +18,7 @@ bluetooth-data-tools==1.14.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.5 -dbus-fast==2.13.1 +dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 446e72d76d8..80970df19e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -660,7 +660,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.13.1 +dbus-fast==2.14.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 226636ef538..575e145e91b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -543,7 +543,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.13.1 +dbus-fast==2.14.0 # homeassistant.components.debugpy debugpy==1.8.0 From e157206eeba713e498ad6501c9fcc9601551804f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 10 Nov 2023 12:22:49 -0600 Subject: [PATCH 376/982] Conversation reload with language=None clears all languages (#103757) Reload with language=None clears all languages --- homeassistant/components/conversation/default_agent.py | 9 +++++---- tests/components/conversation/test_init.py | 9 ++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 09245fde8dc..9dcf70dda80 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -368,10 +368,11 @@ class DefaultAgent(AbstractConversationAgent): async def async_reload(self, language: str | None = None): """Clear cached intents for a language.""" if language is None: - language = self.hass.config.language - - self._lang_intents.pop(language, None) - _LOGGER.debug("Cleared intents for language: %s", language) + self._lang_intents.clear() + _LOGGER.debug("Cleared intents for all languages") + else: + self._lang_intents.pop(language, None) + _LOGGER.debug("Cleared intents for language: %s", language) async def async_prepare(self, language: str | None = None): """Load intents for a language.""" diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 37c8f9401bc..fdbf10b0c7f 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1307,7 +1307,14 @@ async def test_prepare_reload(hass: HomeAssistant) -> None: # Confirm intents are loaded assert agent._lang_intents.get(language) - # Clear cache + # Try to clear for a different language + await hass.services.async_call("conversation", "reload", {"language": "elvish"}) + await hass.async_block_till_done() + + # Confirm intents are still loaded + assert agent._lang_intents.get(language) + + # Clear cache for all languages await hass.services.async_call("conversation", "reload", {}) await hass.async_block_till_done() From ebdd2daab6be57bbcfa789446e6e5c1a8226100b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 10 Nov 2023 19:43:54 +0100 Subject: [PATCH 377/982] Add helper method to get matter device info (#103765) * Add helper method to get matter device info * Cleanup async * Guard * get node from device id instead of node id * Fix test --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/matter/__init__.py | 43 ++++++++++++------- .../components/matter/diagnostics.py | 2 +- homeassistant/components/matter/helpers.py | 13 +++++- homeassistant/components/matter/models.py | 15 +++++++ tests/components/matter/test_helpers.py | 2 +- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index a2aa2c5ceff..b58c4562994 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress +from functools import cache from matter_server.client import MatterClient from matter_server.client.exceptions import CannotConnect, InvalidServerVersion @@ -28,12 +29,34 @@ from .addon import get_addon_manager from .api import async_register_api from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER from .discovery import SUPPORTED_PLATFORMS -from .helpers import MatterEntryData, get_matter, get_node_from_device_entry +from .helpers import ( + MatterEntryData, + get_matter, + get_node_from_device_entry, + node_from_ha_device_id, +) +from .models import MatterDeviceInfo CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 +@callback +@cache +def get_matter_device_info( + hass: HomeAssistant, device_id: str +) -> MatterDeviceInfo | None: + """Return Matter device info or None if device does not exist.""" + if not (node := node_from_ha_device_id(hass, device_id)): + return None + + return MatterDeviceInfo( + unique_id=node.device_info.uniqueID, + vendor_id=hex(node.device_info.vendorID), + product_id=hex(node.device_info.productID), + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Matter from a config entry.""" if use_addon := entry.data.get(CONF_USE_ADDON): @@ -190,7 +213,7 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" - node = await get_node_from_device_entry(hass, device_entry) + node = get_node_from_device_entry(hass, device_entry) if node is None: return True @@ -218,21 +241,11 @@ async def async_remove_config_entry_device( def _async_init_services(hass: HomeAssistant) -> None: """Init services.""" - async def _node_id_from_ha_device_id(ha_device_id: str) -> int | None: - """Get node id from ha device id.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get(ha_device_id) - if device is None: - return None - if node := await get_node_from_device_entry(hass, device): - return node.node_id - return None - async def open_commissioning_window(call: ServiceCall) -> None: """Open commissioning window on specific node.""" - node_id = await _node_id_from_ha_device_id(call.data["device_id"]) + node = node_from_ha_device_id(hass, call.data["device_id"]) - if node_id is None: + if node is None: raise HomeAssistantError("This is not a Matter device") matter_client = get_matter(hass).matter_client @@ -240,7 +253,7 @@ def _async_init_services(hass: HomeAssistant) -> None: # We are sending device ID . try: - await matter_client.open_commissioning_window(node_id) + await matter_client.open_commissioning_window(node.node_id) except NodeCommissionFailed as err: raise HomeAssistantError(str(err)) from err diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index bcb41cc0462..8846a75b42a 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -58,7 +58,7 @@ async def async_get_device_diagnostics( """Return diagnostics for a device.""" matter = get_matter(hass) server_diagnostics = await matter.matter_client.get_diagnostics() - node = await get_node_from_device_entry(hass, device) + node = get_node_from_device_entry(hass, device) return { "server_info": dataclass_to_dict(server_diagnostics.info), diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 0274c80edf8..dcd6a30ee1f 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -66,7 +66,18 @@ def get_device_id( return f"{operational_instance_id}-{postfix}" -async def get_node_from_device_entry( +@callback +def node_from_ha_device_id(hass: HomeAssistant, ha_device_id: str) -> MatterNode | None: + """Get node id from ha device id.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(ha_device_id) + if device is None: + raise ValueError("Invalid device ID") + return get_node_from_device_entry(hass, device) + + +@callback +def get_node_from_device_entry( hass: HomeAssistant, device: dr.DeviceEntry ) -> MatterNode | None: """Return MatterNode from device entry.""" diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 3ac7f66b83f..34447751797 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TypedDict from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -16,6 +17,20 @@ SensorValueTypes = type[ ] +class MatterDeviceInfo(TypedDict): + """Dictionary with Matter Device info. + + Used to send to other Matter controllers, + such as Google Home to prevent duplicated devices. + + Reference: https://developers.home.google.com/matter/device-deduplication + """ + + unique_id: str + vendor_id: str # vendorId hex string + product_id: str # productId hex string + + @dataclass class MatterEntityInfo: """Info discovered from (primary) Matter Attribute to create entity.""" diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index 36761362618..f7399d6aaf1 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -56,7 +56,7 @@ async def test_get_node_from_device_entry( device_registry, config_entry.entry_id )[0] assert device_entry - node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + node_from_device_entry = get_node_from_device_entry(hass, device_entry) assert node_from_device_entry is node From 70f7582f95017b076e37ab448e5a8c5934fdbbcc Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Fri, 10 Nov 2023 19:11:18 +0000 Subject: [PATCH 378/982] Add myself as code owner for ring integration (#103767) --- CODEOWNERS | 2 ++ homeassistant/components/ring/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 1afdf61ac84..6fd15415ff8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1059,6 +1059,8 @@ build.json @home-assistant/supervisor /tests/components/rhasspy/ @balloob @synesthesiam /homeassistant/components/ridwell/ @bachya /tests/components/ridwell/ @bachya +/homeassistant/components/ring/ @sdb9696 +/tests/components/ring/ @sdb9696 /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund /homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 0b5198f36d3..9cea738eb3a 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -1,7 +1,7 @@ { "domain": "ring", "name": "Ring", - "codeowners": [], + "codeowners": ["@sdb9696"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [ From 229944c21c8ba9e58c22d1c39232e7d6cf81f8fa Mon Sep 17 00:00:00 2001 From: G-Two <7310260+G-Two@users.noreply.github.com> Date: Fri, 10 Nov 2023 15:32:10 -0500 Subject: [PATCH 379/982] Bump subarulink to 0.7.9 (#103761) --- homeassistant/components/subaru/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 0c4367c77c8..0cffe2576d1 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.8"] + "requirements": ["subarulink==0.7.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80970df19e3..e1ddb2edd3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2521,7 +2521,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.8 +subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 575e145e91b..fc863932a0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1881,7 +1881,7 @@ stookwijzer==1.3.0 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.8 +subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 From 618b666126a885bfb0ae44571bec0c44ff5bdada Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 10 Nov 2023 15:44:43 -0500 Subject: [PATCH 380/982] Add support for responses to `call_service` WS cmd (#98610) * Add support for responses to call_service WS cmd * Revert ServiceNotFound removal and add a parameter for return_response * fix type * fix tests * remove exception handling that was added * Revert unnecessary modifications * Use kwargs --- .../components/websocket_api/commands.py | 29 ++++--- tests/common.py | 7 +- .../components/websocket_api/test_commands.py | 79 ++++++++++++++++++- 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 471bbc4745a..18688914e8b 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -18,7 +18,14 @@ from homeassistant.const import ( MATCH_ALL, SIGNAL_BOOTSTRAP_INTEGRATIONS, ) -from homeassistant.core import Context, Event, HomeAssistant, State, callback +from homeassistant.core import ( + Context, + Event, + HomeAssistant, + ServiceResponse, + State, + callback, +) from homeassistant.exceptions import ( HomeAssistantError, ServiceNotFound, @@ -213,6 +220,7 @@ def handle_unsubscribe_events( vol.Required("service"): str, vol.Optional("target"): cv.ENTITY_SERVICE_FIELDS, vol.Optional("service_data"): dict, + vol.Optional("return_response", default=False): bool, } ) @decorators.async_response @@ -220,7 +228,6 @@ async def handle_call_service( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle call service command.""" - blocking = True # We do not support templates. target = msg.get("target") if template.is_complex(target): @@ -228,15 +235,19 @@ async def handle_call_service( try: context = connection.context(msg) - await hass.services.async_call( - msg["domain"], - msg["service"], - msg.get("service_data"), - blocking, - context, + response = await hass.services.async_call( + domain=msg["domain"], + service=msg["service"], + service_data=msg.get("service_data"), + blocking=True, + context=context, target=target, + return_response=msg["return_response"], ) - connection.send_result(msg["id"], {"context": context}) + result: dict[str, Context | ServiceResponse] = {"context": context} + if msg["return_response"]: + result["response"] = response + connection.send_result(msg["id"], result) except ServiceNotFound as err: if err.domain == msg["domain"] and err.service == msg["service"]: connection.send_error( diff --git a/tests/common.py b/tests/common.py index cd522aa3320..1737eae21e6 100644 --- a/tests/common.py +++ b/tests/common.py @@ -307,8 +307,11 @@ def async_mock_service( calls.append(call) return response - if supports_response is None and response is not None: - supports_response = SupportsResponse.OPTIONAL + if supports_response is None: + if response is not None: + supports_response = SupportsResponse.OPTIONAL + else: + supports_response = SupportsResponse.NONE hass.services.async_register( domain, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 34424545666..a9551310c2a 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -18,7 +18,7 @@ from homeassistant.components.websocket_api.auth import ( ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS -from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -183,14 +183,76 @@ async def test_call_service( assert call.context.as_dict() == msg["result"]["context"] +async def test_return_response_error(hass: HomeAssistant, websocket_client) -> None: + """Test return_response=True errors when service has no response.""" + hass.services.async_register( + "domain_test", "test_service_with_no_response", lambda x: None + ) + await websocket_client.send_json( + { + "id": 8, + "type": "call_service", + "domain": "domain_test", + "service": "test_service_with_no_response", + "service_data": {"hello": "world"}, + "return_response": True, + }, + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 8 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == "unknown_error" + + @pytest.mark.parametrize("command", ("call_service", "call_service_action")) async def test_call_service_blocking( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, command ) -> None: """Test call service commands block, except for homeassistant restart / stop.""" + async_mock_service( + hass, + "domain_test", + "test_service", + response={"hello": "world"}, + supports_response=SupportsResponse.OPTIONAL, + ) with patch( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: + mock_call.return_value = {"foo": "bar"} + await websocket_client.send_json( + { + "id": 4, + "type": "call_service", + "domain": "domain_test", + "service": "test_service", + "service_data": {"hello": "world"}, + "return_response": True, + }, + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 4 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert msg["result"]["response"] == {"foo": "bar"} + mock_call.assert_called_once_with( + ANY, + "domain_test", + "test_service", + {"hello": "world"}, + blocking=True, + context=ANY, + target=ANY, + return_response=True, + ) + + with patch( + "homeassistant.core.ServiceRegistry.async_call", autospec=True + ) as mock_call: + mock_call.return_value = None await websocket_client.send_json( { "id": 5, @@ -213,11 +275,14 @@ async def test_call_service_blocking( blocking=True, context=ANY, target=ANY, + return_response=False, ) + async_mock_service(hass, "homeassistant", "test_service") with patch( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: + mock_call.return_value = None await websocket_client.send_json( { "id": 6, @@ -239,11 +304,14 @@ async def test_call_service_blocking( blocking=True, context=ANY, target=ANY, + return_response=False, ) + async_mock_service(hass, "homeassistant", "restart") with patch( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: + mock_call.return_value = None await websocket_client.send_json( { "id": 7, @@ -258,7 +326,14 @@ async def test_call_service_blocking( assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( - ANY, "homeassistant", "restart", ANY, blocking=True, context=ANY, target=ANY + ANY, + "homeassistant", + "restart", + ANY, + blocking=True, + context=ANY, + target=ANY, + return_response=False, ) From 531a3e4fa85d94fc409dacde28485bb1199b837a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 10 Nov 2023 20:46:15 +0000 Subject: [PATCH 381/982] Bump accuweather to version 2.1.0 (#103744) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 307d68c4b7b..b74711ccbe6 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==2.0.1"] + "requirements": ["accuweather==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e1ddb2edd3d..6624e9c2594 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,7 +147,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.0.1 +accuweather==2.1.0 # homeassistant.components.adax adax==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc863932a0a..5f20e579e47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ Tami4EdgeAPI==2.1 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.0.1 +accuweather==2.1.0 # homeassistant.components.adax adax==0.3.0 From eaaca3e5568efb8e1dbaeae53e2f3bef1ed21ce1 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sat, 11 Nov 2023 01:33:02 +0100 Subject: [PATCH 382/982] Add translations for update entity components (#103752) Co-authored-by: Franck Nijhof --- homeassistant/components/update/strings.json | 40 +++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index 1d238d3dd51..eb6db257bb2 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -9,7 +9,45 @@ }, "entity_component": { "_": { - "name": "[%key:component::update::title%]" + "name": "[%key:component::update::title%]", + "state": { + "on": "Update available", + "off": "Up-to-date" + }, + "state_attributes": { + "in_progress": { + "name": "In progress", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "auto_update": { + "name": "Auto update", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "title": { + "name": "Title" + }, + "skipped_version": { + "name": "Skipped version" + }, + "release_url": { + "name": "Release URL" + }, + "release_summary": { + "name": "Release summary" + }, + "installed_version": { + "name": "Installed version" + }, + "latest_version": { + "name": "Latest version" + } + } }, "firmware": { "name": "Firmware" From 3f70437888a19ad0c35fbc93a2943a4b42fc3fb9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 10 Nov 2023 22:49:10 -0800 Subject: [PATCH 383/982] Add support to Google Calendar for Web auth credentials (#103570) * Add support to Google Calendar for webauth credentials * Fix broken import * Fix credential name used on import in test * Remove unnecessary creds domain parameters * Remove unnecessary guard to improve code coverage * Clarify comments about credential preferences * Fix typo --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/google/api.py | 15 +- .../google/application_credentials.py | 4 +- .../components/google/config_flow.py | 67 ++++- homeassistant/components/google/const.py | 11 +- tests/components/google/conftest.py | 6 +- tests/components/google/test_config_flow.py | 229 +++++++++++++++++- 6 files changed, 307 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index f37e120db68..8ed18cca41c 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -45,11 +45,18 @@ class OAuthError(Exception): """OAuth related error.""" -class DeviceAuth(AuthImplementation): - """OAuth implementation for Device Auth.""" +class InvalidCredential(OAuthError): + """Error with an invalid credential that does not support device auth.""" + + +class GoogleHybridAuth(AuthImplementation): + """OAuth implementation that supports both Web Auth (base class) and Device Auth.""" async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" + if DEVICE_AUTH_CREDS not in external_data: + # Assume the Web Auth flow was used, so use the default behavior + return await super().async_resolve_external_data(external_data) creds: Credentials = external_data[DEVICE_AUTH_CREDS] delta = creds.token_expiry.replace(tzinfo=datetime.UTC) - dt_util.utcnow() _LOGGER.debug( @@ -192,6 +199,10 @@ async def async_create_device_flow( oauth_flow.step1_get_device_and_user_codes ) except OAuth2DeviceCodeError as err: + _LOGGER.debug("OAuth2DeviceCodeError error: %s", err) + # Web auth credentials reply with invalid_client when hitting this endpoint + if "Error: invalid_client" in str(err): + raise InvalidCredential(str(err)) from err raise OAuthError(str(err)) from err return DeviceFlow(hass, oauth_flow, device_flow_info) diff --git a/homeassistant/components/google/application_credentials.py b/homeassistant/components/google/application_credentials.py index 60ad9b3275e..bb1ddfef5d7 100644 --- a/homeassistant/components/google/application_credentials.py +++ b/homeassistant/components/google/application_credentials.py @@ -9,7 +9,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .api import DeviceAuth +from .api import GoogleHybridAuth AUTHORIZATION_SERVER = AuthorizationServer( oauth2client.GOOGLE_AUTH_URI, oauth2client.GOOGLE_TOKEN_URI @@ -20,7 +20,7 @@ async def async_get_auth_implementation( hass: HomeAssistant, auth_domain: str, credential: ClientCredential ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: """Return auth implementation.""" - return DeviceAuth(hass, auth_domain, credential, AUTHORIZATION_SERVER) + return GoogleHybridAuth(hass, auth_domain, credential, AUTHORIZATION_SERVER) async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 1945afe15e9..33d913fe8f1 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -18,13 +18,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import ( DEVICE_AUTH_CREDS, AccessTokenAuthImpl, - DeviceAuth, DeviceFlow, + GoogleHybridAuth, + InvalidCredential, OAuthError, async_create_device_flow, get_feature_access, ) -from .const import CONF_CALENDAR_ACCESS, DOMAIN, FeatureAccess +from .const import ( + CONF_CALENDAR_ACCESS, + CONF_CREDENTIAL_TYPE, + DEFAULT_FEATURE_ACCESS, + DOMAIN, + CredentialType, + FeatureAccess, +) _LOGGER = logging.getLogger(__name__) @@ -32,7 +40,31 @@ _LOGGER = logging.getLogger(__name__) class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): - """Config flow to handle Google Calendars OAuth2 authentication.""" + """Config flow to handle Google Calendars OAuth2 authentication. + + Historically, the Google Calendar integration instructed users to use + Device Auth. Device Auth was considered easier to use since it did not + require users to configure a redirect URL. Device Auth is meant for + devices with limited input, such as a television. + https://developers.google.com/identity/protocols/oauth2/limited-input-device + + Device Auth is limited to a small set of Google APIs (calendar is allowed) + and is considered less secure than Web Auth. It is not generally preferred + and may be limited/deprecated in the future similar to App/OOB Auth + https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html + + Web Auth is the preferred method by Home Assistant and Google, and a benefit + is that the same credentials may be used across many Google integrations in + Home Assistant. Web Auth is now easier for user to setup using my.home-assistant.io + redirect urls. + + The Application Credentials integration does not currently record which type + of credential the user entered (and if we ask the user, they may not know or may + make a mistake) so we try to determine the credential type automatically. This + implementation first attempts Device Auth by talking to the token API in the first + step of the device flow, then if that fails it will redirect using Web Auth. + There is not another explicit known way to check. + """ DOMAIN = DOMAIN @@ -41,12 +73,24 @@ class OAuth2FlowHandler( super().__init__() self._reauth_config_entry: config_entries.ConfigEntry | None = None self._device_flow: DeviceFlow | None = None + # First attempt is device auth, then fallback to web auth + self._web_auth = False @property def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": DEFAULT_FEATURE_ACCESS.scope, + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + async def async_step_import(self, info: dict[str, Any]) -> FlowResult: """Import existing auth into a new config entry.""" if self._async_current_entries(): @@ -68,12 +112,15 @@ class OAuth2FlowHandler( # prompt the user to visit a URL and enter a code. The device flow # background task will poll the exchange endpoint to get valid # creds or until a timeout is complete. + if self._web_auth: + return await super().async_step_auth(user_input) + if user_input is not None: return self.async_show_progress_done(next_step_id="creation") if not self._device_flow: - _LOGGER.debug("Creating DeviceAuth flow") - if not isinstance(self.flow_impl, DeviceAuth): + _LOGGER.debug("Creating GoogleHybridAuth flow") + if not isinstance(self.flow_impl, GoogleHybridAuth): _LOGGER.error( "Unexpected OAuth implementation does not support device auth: %s", self.flow_impl, @@ -94,6 +141,10 @@ class OAuth2FlowHandler( except TimeoutError as err: _LOGGER.error("Timeout initializing device flow: %s", str(err)) return self.async_abort(reason="timeout_connect") + except InvalidCredential: + _LOGGER.debug("Falling back to Web Auth and restarting flow") + self._web_auth = True + return await super().async_step_auth() except OAuthError as err: _LOGGER.error("Error initializing device flow: %s", str(err)) return self.async_abort(reason="oauth_error") @@ -125,12 +176,15 @@ class OAuth2FlowHandler( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle external yaml configuration.""" - if self.external_data.get(DEVICE_AUTH_CREDS) is None: + if not self._web_auth and self.external_data.get(DEVICE_AUTH_CREDS) is None: return self.async_abort(reason="code_expired") return await super().async_step_creation(user_input) async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow, or update existing entry.""" + data[CONF_CREDENTIAL_TYPE] = ( + CredentialType.WEB_AUTH if self._web_auth else CredentialType.DEVICE_AUTH + ) if self._reauth_config_entry: self.hass.config_entries.async_update_entry( self._reauth_config_entry, data=data @@ -170,6 +224,7 @@ class OAuth2FlowHandler( self._reauth_config_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + self._web_auth = entry_data.get(CONF_CREDENTIAL_TYPE) == CredentialType.WEB_AUTH return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index add98441e39..6f497543b2d 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -1,12 +1,12 @@ """Constants for google integration.""" from __future__ import annotations -from enum import Enum +from enum import Enum, StrEnum DOMAIN = "google" -DEVICE_AUTH_IMPL = "device_auth" CONF_CALENDAR_ACCESS = "calendar_access" +CONF_CREDENTIAL_TYPE = "credential_type" DATA_CALENDARS = "calendars" DATA_SERVICE = "service" DATA_CONFIG = "config" @@ -32,6 +32,13 @@ class FeatureAccess(Enum): DEFAULT_FEATURE_ACCESS = FeatureAccess.read_write +class CredentialType(StrEnum): + """Type of application credentials used.""" + + DEVICE_AUTH = "device_auth" + WEB_AUTH = "web_auth" + + EVENT_DESCRIPTION = "description" EVENT_END_DATE = "end_date" EVENT_END_DATETIME = "end_date_time" diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index d938a2f3291..3b2ed6d24e1 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -218,7 +218,7 @@ def config_entry( domain=DOMAIN, unique_id=config_entry_unique_id, data={ - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", @@ -350,7 +350,9 @@ def component_setup( async def _setup_func() -> bool: assert await async_setup_component(hass, "application_credentials", {}) await async_import_client_credential( - hass, DOMAIN, ClientCredential("client-id", "client-secret"), "device_auth" + hass, + DOMAIN, + ClientCredential("client-id", "client-secret"), ) config_entry.add_to_hass(hass) return await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index aa8976bda21..f534f624bf6 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable import datetime from http import HTTPStatus +from typing import Any from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError @@ -21,9 +22,14 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.google.const import DOMAIN +from homeassistant.components.google.const import ( + CONF_CREDENTIAL_TYPE, + DOMAIN, + CredentialType, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -31,9 +37,13 @@ from homeassistant.util.dt import utcnow from .conftest import CLIENT_ID, CLIENT_SECRET, EMAIL_ADDRESS, YieldFixture from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator CODE_CHECK_INTERVAL = 1 CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" @pytest.fixture(autouse=True) @@ -175,6 +185,7 @@ async def test_full_flow_application_creds( "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert result.get("options") == {"calendar_access": "read_write"} @@ -230,7 +241,9 @@ async def test_expired_after_exchange( ) -> None: """Test credential exchange expires.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -262,7 +275,9 @@ async def test_exchange_error( ) -> None: """Test an error while exchanging the code for credentials.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -307,13 +322,14 @@ async def test_exchange_error( data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert len(mock_setup.mock_calls) == 1 @@ -329,7 +345,7 @@ async def test_duplicate_config_entries( ) -> None: """Test that the same account cannot be setup twice.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) # Load a config entry @@ -371,7 +387,7 @@ async def test_multiple_config_entries( ) -> None: """Test that multiple config entries can be set at once.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) # Load a config entry @@ -455,17 +471,19 @@ async def test_reauth_flow( mock_code_flow: Mock, mock_exchange: Mock, ) -> None: - """Test can't configure when config entry already exists.""" + """Test reauth of an existing config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": {"access_token": "OLD_ACCESS_TOKEN"}, }, ) config_entry.add_to_hass(hass) await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) entries = hass.config_entries.async_entries(DOMAIN) @@ -512,13 +530,14 @@ async def test_reauth_flow( data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert len(mock_setup.mock_calls) == 1 @@ -540,7 +559,9 @@ async def test_calendar_lookup_failure( ) -> None: """Test successful config flow and title fetch fails gracefully.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -624,3 +645,189 @@ async def test_options_flow_no_changes( ) assert result["type"] == "create_entry" assert config_entry.options == {"calendar_access": "read_write"} + + +async def test_web_auth_compatibility( + hass: HomeAssistant, + current_request_with_host: None, + mock_code_flow: Mock, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test that we can callback to web auth tokens.""" + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + with patch( + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", + side_effect=OAuth2DeviceCodeError( + "Invalid response 401. Error: invalid_client" + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["type"] == "external" + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/calendar" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "https://www.googleapis.com/auth/calendar", + }, + ) + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.CREATE_ENTRY + token = result.get("data", {}).get("token", {}) + del token["expires_at"] + assert token == { + "access_token": "mock-access-token", + "expires_in": 60, + "refresh_token": "mock-refresh-token", + "type": "Bearer", + "scope": "https://www.googleapis.com/auth/calendar", + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize( + "entry_data", + [ + {}, + {CONF_CREDENTIAL_TYPE: CredentialType.WEB_AUTH}, + ], +) +async def test_web_reauth_flow( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + entry_data: dict[str, Any], +) -> None: + """Test reauth of an existing config entry with a web credential.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + **entry_data, + "auth_implementation": DOMAIN, + "token": {"access_token": "OLD_ACCESS_TOKEN"}, + }, + ) + config_entry.add_to_hass(hass) + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) + ) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", + side_effect=OAuth2DeviceCodeError( + "Invalid response 401. Error: invalid_client" + ), + ): + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result.get("type") == "external" + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/calendar" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 60, + "scope": "https://www.googleapis.com/auth/calendar", + }, + ) + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + data = dict(entries[0].data) + data["token"].pop("expires_at") + data["token"].pop("expires_in") + assert data == { + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "scope": "https://www.googleapis.com/auth/calendar", + "token_type": "Bearer", + }, + "credential_type": "web_auth", + } + + assert len(mock_setup.mock_calls) == 1 From 667a453a3523d7d7f513f3859c8564109b8af240 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 11 Nov 2023 09:39:41 +0100 Subject: [PATCH 384/982] Lock Withings token refresh (#103688) Lock Withings refresh --- homeassistant/components/withings/__init__.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 496aba290ba..701f7f444cf 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -166,12 +166,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) oauth_session = OAuth2Session(hass, entry, implementation) + refresh_lock = asyncio.Lock() + async def _refresh_token() -> str: - await oauth_session.async_ensure_token_valid() - token = oauth_session.token[CONF_ACCESS_TOKEN] - if TYPE_CHECKING: - assert isinstance(token, str) - return token + async with refresh_lock: + await oauth_session.async_ensure_token_valid() + token = oauth_session.token[CONF_ACCESS_TOKEN] + if TYPE_CHECKING: + assert isinstance(token, str) + return token client.refresh_token_function = _refresh_token withings_data = WithingsData( From 787fb3b954bdb1b7ac5cde71b024cb5e2c602e59 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 11 Nov 2023 02:02:51 -0800 Subject: [PATCH 385/982] Improve OAuth error handling in configuration flows (#103157) * Improve OAuth error handling in configuration flows * Update strings for all integrations that use oauth2 config flow * Remove invalid_auth strings * Revert change to release * Revert close change in aiohttp mock --- .../components/electric_kiwi/strings.json | 19 +- homeassistant/components/fitbit/strings.json | 5 +- .../components/geocaching/strings.json | 25 +-- homeassistant/components/google/strings.json | 5 +- .../google_assistant_sdk/strings.json | 5 +- .../components/google_mail/strings.json | 5 +- .../components/google_sheets/strings.json | 9 +- .../components/google_tasks/strings.json | 5 +- .../components/home_connect/strings.json | 31 ++-- .../components/home_plus_control/strings.json | 6 +- .../components/lametric/strings.json | 6 +- homeassistant/components/lyric/strings.json | 6 +- homeassistant/components/neato/strings.json | 6 +- homeassistant/components/nest/strings.json | 6 +- homeassistant/components/netatmo/strings.json | 6 +- .../components/ondilo_ico/strings.json | 6 +- homeassistant/components/senz/strings.json | 5 +- homeassistant/components/smappee/strings.json | 6 +- homeassistant/components/spotify/strings.json | 6 +- homeassistant/components/toon/strings.json | 6 +- homeassistant/components/twitch/strings.json | 6 +- .../components/withings/strings.json | 5 +- homeassistant/components/xbox/strings.json | 6 +- homeassistant/components/yolink/strings.json | 36 ++-- homeassistant/components/youtube/strings.json | 22 +-- .../helpers/config_entry_oauth2_flow.py | 28 ++- homeassistant/strings.json | 2 + script/scaffold/generate.py | 3 + tests/components/nest/test_config_flow.py | 56 +++++- .../helpers/test_config_entry_oauth2_flow.py | 162 +++++++++++++++++- tests/test_util/aiohttp.py | 19 +- 31 files changed, 395 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 81de5cef896..4a67bd5211b 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -17,7 +17,10 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -25,17 +28,9 @@ }, "entity": { "sensor": { - "hopfreepowerstart": { - "name": "Hour of free power start" - }, - "hopfreepowerend": { - "name": "Hour of free power end" - } + "hopfreepowerstart": { "name": "Hour of free power start" }, + "hopfreepowerend": { "name": "Hour of free power end" } }, - "select": { - "hopselector": { - "name": "Hour of free power" - } - } + "select": { "hopselector": { "name": "Hour of free power" } } } } diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 7e85e232099..d941121e4da 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -22,7 +22,10 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "The user credentials provided do not match this Fitbit account." + "wrong_account": "The user credentials provided do not match this Fitbit account.", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index 6dc2fe8ec1c..fd431860cd2 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -16,7 +16,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -24,21 +27,11 @@ }, "entity": { "sensor": { - "find_count": { - "name": "Total finds" - }, - "hide_count": { - "name": "Total hides" - }, - "favorite_points": { - "name": "Favorite points" - }, - "souvenir_count": { - "name": "Total souvenirs" - }, - "awarded_favorite_points": { - "name": "Awarded favorite points" - } + "find_count": { "name": "Total finds" }, + "hide_count": { "name": "Total hides" }, + "favorite_points": { "name": "Favorite points" }, + "souvenir_count": { "name": "Total souvenirs" }, + "awarded_favorite_points": { "name": "Awarded favorite points" } } } } diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index b3594f31510..9327009bda3 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -22,7 +22,10 @@ "code_expired": "Authentication code expired or credential setup is invalid, please try again.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console" + "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index e9e2b7d4c09..fa86e207a9c 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -21,7 +21,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 2bd70750ff9..3ed1c2377d5 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -22,7 +22,10 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with {email}." + "wrong_account": "Wrong account: Please authenticate with {email}.", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index b2cba19031e..ea327097d88 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -4,9 +4,7 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { - "title": "Link Google Account" - }, + "auth": { "title": "Link Google Account" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Google Sheets integration needs to re-authenticate your account" @@ -23,7 +21,10 @@ "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "unknown": "[%key:common::config_flow::error::unknown%]", "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", - "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details" + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "Successfully authenticated and spreadsheet created at: {url}" diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index f15c31f42d4..d730f4cb770 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -17,7 +17,10 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "access_not_configured": "Unable to access the Google API:\n\n{message}", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 091f0c18232..7ee44089b28 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -7,7 +7,11 @@ }, "abort": { "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -22,22 +26,13 @@ "name": "Device ID", "description": "Id of the device." }, - "program": { - "name": "Program", - "description": "Program to select." - }, - "key": { - "name": "Option key", - "description": "Key of the option." - }, + "program": { "name": "Program", "description": "Program to select." }, + "key": { "name": "Option key", "description": "Key of the option." }, "value": { "name": "Option value", "description": "Value of the option." }, - "unit": { - "name": "Option unit", - "description": "Unit for the option." - } + "unit": { "name": "Option unit", "description": "Unit for the option." } } }, "select_program": { @@ -130,14 +125,8 @@ "name": "Device ID", "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" }, - "key": { - "name": "Key", - "description": "Key of the setting." - }, - "value": { - "name": "Value", - "description": "Value of the setting." - } + "key": { "name": "Key", "description": "Key of the setting." }, + "value": { "name": "Value", "description": "Value of the setting." } } } } diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index 280a92055bd..c35650a5183 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -11,7 +11,11 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index e7bfc059674..4069cb41bdd 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -41,7 +41,11 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_device_not_found": "The device you are trying to re-authenticate is not found in this LaMetric account", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" } }, "entity": { diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 219530a9747..6b594654dfa 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -12,7 +12,11 @@ "abort": { "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index d611abb83b0..3dcceecd1e3 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -13,7 +13,11 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 717ce5075f7..a54ac82a9a7 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -49,7 +49,11 @@ "unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 593320827fd..99f780dbe3e 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -14,7 +14,11 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/ondilo_ico/strings.json b/homeassistant/components/ondilo_ico/strings.json index 3843670bc50..7b049e66ae2 100644 --- a/homeassistant/components/ondilo_ico/strings.json +++ b/homeassistant/components/ondilo_ico/strings.json @@ -7,7 +7,11 @@ }, "abort": { "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]" + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/senz/strings.json b/homeassistant/components/senz/strings.json index 316f7234f9b..693cfe3415b 100644 --- a/homeassistant/components/senz/strings.json +++ b/homeassistant/components/senz/strings.json @@ -11,7 +11,10 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 58abeb57186..9322170dfdf 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -29,7 +29,11 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "invalid_mdns": "Unsupported device for the Smappee integration.", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" } } } diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index ec2721aba8b..b53b600d5ba 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -13,7 +13,11 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication." + "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication.", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "Successfully authenticated with Spotify." diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index 620a7f51113..fb05a15db00 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -18,7 +18,11 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_agreements": "This account has no Toon displays.", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" } }, "services": { diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json index 45f88747128..3bda5284c0f 100644 --- a/homeassistant/components/twitch/strings.json +++ b/homeassistant/components/twitch/strings.json @@ -10,7 +10,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with {username}." + "wrong_account": "Wrong account: Please authenticate with {username}.", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" } }, "issues": { diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index fb447f3578e..645ab135300 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -17,7 +17,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "already_configured": "Configuration updated for profile.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]" + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "Successfully authenticated with Withings." diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index accd6775941..e011194dc7c 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -8,7 +8,11 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]" + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index b1cd8d87a75..07df1008653 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -16,7 +16,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -36,21 +39,11 @@ }, "entity": { "switch": { - "usb_ports": { - "name": "USB ports" - }, - "plug_1": { - "name": "Plug 1" - }, - "plug_2": { - "name": "Plug 2" - }, - "plug_3": { - "name": "Plug 3" - }, - "plug_4": { - "name": "Plug 4" - } + "usb_ports": { "name": "USB ports" }, + "plug_1": { "name": "Plug 1" }, + "plug_2": { "name": "Plug 2" }, + "plug_3": { "name": "Plug 3" }, + "plug_4": { "name": "Plug 4" } }, "sensor": { "power_failure_alarm": { @@ -63,18 +56,11 @@ }, "power_failure_alarm_mute": { "name": "Power failure alarm mute", - "state": { - "muted": "Muted", - "unmuted": "Unmuted" - } + "state": { "muted": "Muted", "unmuted": "Unmuted" } }, "power_failure_alarm_volume": { "name": "Power failure alarm volume", - "state": { - "low": "Low", - "medium": "Medium", - "high": "High" - } + "state": { "low": "Low", "medium": "Medium", "high": "High" } }, "power_failure_alarm_beep": { "name": "Power failure alarm beep", diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 1b9ecbc1cb3..0bd62a42314 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -6,7 +6,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "no_subscriptions": "You need to be subscribed to YouTube channels in order to add them.", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", @@ -15,9 +19,7 @@ "step": { "channels": { "description": "Select the channels you want to add.", - "data": { - "channels": "YouTube channels" - } + "data": { "channels": "YouTube channels" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", @@ -40,17 +42,11 @@ "latest_upload": { "name": "Latest upload", "state_attributes": { - "video_id": { - "name": "Video ID" - }, - "published_at": { - "name": "Published at" - } + "video_id": { "name": "Video ID" }, + "published_at": { "name": "Published at" } } }, - "subscribers": { - "name": "Subscribers" - } + "subscribers": { "name": "Subscribers" } } } } diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 1a106364566..5b4b803a8d4 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -10,12 +10,14 @@ from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import asyncio from collections.abc import Awaitable, Callable +from http import HTTPStatus +from json import JSONDecodeError import logging import secrets import time from typing import Any, cast -from aiohttp import client, web +from aiohttp import ClientError, ClientResponseError, client, web import jwt import voluptuous as vol from yarl import URL @@ -199,12 +201,15 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): _LOGGER.debug("Sending token request to %s", self.token_url) resp = await session.post(self.token_url, data=data) - if resp.status >= 400 and _LOGGER.isEnabledFor(logging.DEBUG): - body = await resp.text() - _LOGGER.debug( - "Token request failed with status=%s, body=%s", - resp.status, - body, + if resp.status >= 400: + try: + error_response = await resp.json() + except (ClientError, JSONDecodeError): + error_response = {} + error_code = error_response.get("error", "unknown") + error_description = error_response.get("error_description", "unknown error") + _LOGGER.error( + "Token request failed (%s): %s", error_code, error_description ) resp.raise_for_status() return cast(dict, await resp.json()) @@ -317,7 +322,14 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): ) except asyncio.TimeoutError as err: _LOGGER.error("Timeout resolving OAuth token: %s", err) - return self.async_abort(reason="oauth2_timeout") + return self.async_abort(reason="oauth_timeout") + except (ClientResponseError, ClientError) as err: + if ( + isinstance(err, ClientResponseError) + and err.status == HTTPStatus.UNAUTHORIZED + ): + return self.async_abort(reason="oauth_unauthorized") + return self.async_abort(reason="oauth_failed") if "expires_in" not in token: _LOGGER.warning("Invalid token: %s", token) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index f41380fc9e5..6e6499e0d19 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -126,6 +126,8 @@ "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", "oauth2_user_rejected_authorize": "Account linking rejected: {error}", + "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", + "oauth2_failed": "Error while obtaining access token.", "reauth_successful": "Re-authentication was successful", "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", "cloud_not_connected": "Not connected to Home Assistant Cloud." diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index e31df7ecf0f..ff503bc12db 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -185,6 +185,9 @@ def _custom_tasks(template, info: Info) -> None: "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 7ab4a6dafc1..b483b752e77 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Google Nest Device Access config flow.""" from __future__ import annotations +from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -27,7 +28,9 @@ from .common import ( SUBSCRIBER_ID, TEST_CONFIG_APP_CREDS, TEST_CONFIGFLOW_APP_CREDS, + FakeSubscriber, NestTestConfig, + PlatformSetup, ) from tests.common import MockConfigEntry @@ -92,8 +95,6 @@ class OAuthFixture: assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - await self.async_mock_refresh(result) - async def async_reauth(self, config_entry: ConfigEntry) -> dict: """Initiate a reuath flow.""" config_entry.async_start_reauth(self.hass) @@ -137,7 +138,7 @@ class OAuthFixture: "&access_type=offline&prompt=consent" ) - async def async_mock_refresh(self, result, user_input: dict = None) -> None: + def async_mock_refresh(self) -> None: """Finish the OAuth flow exchanging auth token for refresh token.""" self.aioclient_mock.post( OAUTH2_TOKEN, @@ -202,6 +203,7 @@ async def test_app_credentials( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result) @@ -235,6 +237,7 @@ async def test_config_flow_restart( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() # At this point, we should have a valid auth implementation configured. # Simulate aborting the flow and starting over to ensure we get prompted @@ -254,6 +257,7 @@ async def test_config_flow_restart( result = await oauth.async_configure(result, {"project_id": "new-project-id"}) await oauth.async_oauth_web_flow(result, "new-project-id") + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -305,6 +309,7 @@ async def test_config_flow_wrong_project_id( result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) await oauth.async_oauth_web_flow(result) await hass.async_block_till_done() + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -341,6 +346,7 @@ async def test_config_flow_pubsub_configuration_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() mock_subscriber.create_subscription.side_effect = ConfigurationException result = await oauth.async_configure(result, {"code": "1234"}) @@ -360,6 +366,7 @@ async def test_config_flow_pubsub_subscriber_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() mock_subscriber.create_subscription.side_effect = SubscriberException() result = await oauth.async_configure(result, {"code": "1234"}) @@ -384,6 +391,7 @@ async def test_multiple_config_entries( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result, project_id="project-id-2") + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result) assert entry.title == "Mock Title" assert "token" in entry.data @@ -442,6 +450,7 @@ async def test_reauth_multiple_config_entries( result = await oauth.async_reauth(config_entry) await oauth.async_oauth_web_flow(result) + oauth.async_mock_refresh() await oauth.async_finish_setup(result) @@ -479,6 +488,7 @@ async def test_pubsub_subscription_strip_whitespace( await oauth.async_app_creds_flow( result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " ) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Import from configuration.yaml" @@ -508,6 +518,7 @@ async def test_pubsub_subscription_auth_failure( mock_subscriber.create_subscription.side_effect = AuthException() await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() result = await oauth.async_configure(result, {"code": "1234"}) assert result["type"] == "abort" @@ -527,6 +538,7 @@ async def test_pubsub_subscriber_config_entry_reauth( result = await oauth.async_reauth(config_entry) await oauth.async_oauth_web_flow(result) + oauth.async_mock_refresh() # Entering an updated access token refreshes the config entry. entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -568,6 +580,7 @@ async def test_config_entry_title_from_home( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home" @@ -613,6 +626,7 @@ async def test_config_entry_title_multiple_homes( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home #1, Example Home #2" @@ -628,6 +642,7 @@ async def test_title_failure_fallback( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() mock_subscriber.async_get_device_manager.side_effect = AuthException() entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -659,6 +674,7 @@ async def test_structure_missing_trait( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) # Fallback to default name @@ -705,6 +721,7 @@ async def test_dhcp_discovery_with_creds( result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) await oauth.async_oauth_web_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) await hass.async_block_till_done() @@ -726,3 +743,36 @@ async def test_dhcp_discovery_with_creds( "type": "Bearer", }, } + + +@pytest.mark.parametrize( + ("status_code", "error_reason"), + [ + (HTTPStatus.UNAUTHORIZED, "oauth_unauthorized"), + (HTTPStatus.NOT_FOUND, "oauth_failed"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "oauth_failed"), + ], +) +async def test_token_error( + hass: HomeAssistant, + oauth: OAuthFixture, + subscriber: FakeSubscriber, + setup_platform: PlatformSetup, + status_code: HTTPStatus, + error_reason: str, +) -> None: + """Check full flow.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + oauth.aioclient_mock.post( + OAUTH2_TOKEN, + status=status_code, + ) + + result = await oauth.async_configure(result, user_input=None) + assert result.get("type") == "abort" + assert result.get("reason") == error_reason diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index c36b62f66c0..8c78b7dadc6 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -1,7 +1,9 @@ """Tests for the Somfy config flow.""" import asyncio +from http import HTTPStatus import logging import time +from typing import Any from unittest.mock import patch import aiohttp @@ -339,7 +341,7 @@ async def test_abort_on_oauth_timeout_error( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "oauth2_timeout" + assert result["reason"] == "oauth_timeout" async def test_step_discovery(hass: HomeAssistant, flow_handler, local_impl) -> None: @@ -387,6 +389,164 @@ async def test_abort_discovered_multiple( assert result["reason"] == "already_in_progress" +@pytest.mark.parametrize( + ("status_code", "error_body", "error_reason", "error_log"), + [ + ( + HTTPStatus.UNAUTHORIZED, + {}, + "oauth_unauthorized", + "Token request failed (unknown): unknown", + ), + ( + HTTPStatus.NOT_FOUND, + {}, + "oauth_failed", + "Token request failed (unknown): unknown", + ), + ( + HTTPStatus.INTERNAL_SERVER_ERROR, + {}, + "oauth_failed", + "Token request failed (unknown): unknown", + ), + ( + HTTPStatus.BAD_REQUEST, + { + "error": "invalid_request", + "error_description": "Request was missing the 'redirect_uri' parameter.", + "error_uri": "See the full API docs at https://authorization-server.com/docs/access_token", + }, + "oauth_failed", + "Token request failed (invalid_request): Request was missing the", + ), + ], +) +async def test_abort_if_oauth_token_error( + hass: HomeAssistant, + flow_handler, + local_impl, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + status_code: HTTPStatus, + error_body: dict[str, Any], + error_reason: str, + error_log: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Check error when obtaining an oauth token.""" + flow_handler.async_register_implementation(hass, local_impl) + config_entry_oauth2_flow.async_register_implementation( + hass, TEST_DOMAIN, MockOAuth2Implementation() + ) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "pick_implementation" + + # Pick implementation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"implementation": TEST_DOMAIN} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=read+write" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + TOKEN_URL, + status=status_code, + json=error_body, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == error_reason + assert error_log in caplog.text + + +async def test_abort_if_oauth_token_closing_error( + hass: HomeAssistant, + flow_handler, + local_impl, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Check error when obtaining an oauth token.""" + flow_handler.async_register_implementation(hass, local_impl) + config_entry_oauth2_flow.async_register_implementation( + hass, TEST_DOMAIN, MockOAuth2Implementation() + ) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "pick_implementation" + + # Pick implementation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"implementation": TEST_DOMAIN} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=read+write" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + TOKEN_URL, + status=HTTPStatus.UNAUTHORIZED, + closing=True, + ) + + with caplog.at_level(logging.DEBUG): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert "Token request failed (unknown): unknown" in caplog.text + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "oauth_unauthorized" + + async def test_abort_discovered_existing_entries( hass: HomeAssistant, flow_handler, local_impl ) -> None: diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 356240dc37a..ac874fcc45c 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -7,7 +7,11 @@ from unittest import mock from urllib.parse import parse_qs from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientError, ClientResponseError +from aiohttp.client_exceptions import ( + ClientConnectionError, + ClientError, + ClientResponseError, +) from aiohttp.streams import StreamReader from multidict import CIMultiDict from yarl import URL @@ -53,6 +57,7 @@ class AiohttpClientMocker: exc=None, cookies=None, side_effect=None, + closing=None, ): """Mock a request.""" if not isinstance(url, RETYPE): @@ -72,6 +77,7 @@ class AiohttpClientMocker: exc=exc, headers=headers, side_effect=side_effect, + closing=closing, ) ) @@ -165,6 +171,7 @@ class AiohttpClientMockResponse: exc=None, headers=None, side_effect=None, + closing=None, ): """Initialize a fake response.""" if json is not None: @@ -178,9 +185,10 @@ class AiohttpClientMockResponse: self.method = method self._url = url self.status = status - self.response = response + self._response = response self.exc = exc self.side_effect = side_effect + self.closing = closing self._headers = CIMultiDict(headers or {}) self._cookies = {} @@ -272,6 +280,13 @@ class AiohttpClientMockResponse: def close(self): """Mock close.""" + @property + def response(self): + """Property method to expose the response to other read methods.""" + if self.closing: + raise ClientConnectionError("Connection closed") + return self._response + @contextmanager def mock_aiohttp_client(): From f3cccf0a2bd6a322561de728a1ceadd9e93ebce3 Mon Sep 17 00:00:00 2001 From: Alex Tsernoh Date: Sat, 11 Nov 2023 12:19:41 +0200 Subject: [PATCH 386/982] Add Komfovent (#95722) * komfovent integration V1 * add dependency * integrate komfovent api * fix errors found in testing * tests for form handling * update deps * update coverage rc * add correct naming * minor feedback * pre-commit fixes * feedback fixes part 1 of 2 * feedback fixes part 2 of 2 * add hvac mode support * fix tests * address feedback * fix code coverage + PR feedback * PR feedback * use device name --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 2 + CODEOWNERS | 2 + .../components/komfovent/__init__.py | 34 ++++ homeassistant/components/komfovent/climate.py | 91 +++++++++ .../components/komfovent/config_flow.py | 74 +++++++ homeassistant/components/komfovent/const.py | 3 + .../components/komfovent/manifest.json | 9 + .../components/komfovent/strings.json | 22 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/komfovent/__init__.py | 1 + tests/components/komfovent/conftest.py | 14 ++ .../components/komfovent/test_config_flow.py | 189 ++++++++++++++++++ 15 files changed, 454 insertions(+) create mode 100644 homeassistant/components/komfovent/__init__.py create mode 100644 homeassistant/components/komfovent/climate.py create mode 100644 homeassistant/components/komfovent/config_flow.py create mode 100644 homeassistant/components/komfovent/const.py create mode 100644 homeassistant/components/komfovent/manifest.json create mode 100644 homeassistant/components/komfovent/strings.json create mode 100644 tests/components/komfovent/__init__.py create mode 100644 tests/components/komfovent/conftest.py create mode 100644 tests/components/komfovent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 7ea4a1f5501..ebec3974cbb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -639,6 +639,8 @@ omit = homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py + homeassistant/components/komfovent/__init__.py + homeassistant/components/komfovent/climate.py homeassistant/components/konnected/__init__.py homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 6fd15415ff8..f6737c2e044 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -664,6 +664,8 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund +/homeassistant/components/komfovent/ @ProstoSanja +/tests/components/komfovent/ @ProstoSanja /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm diff --git a/homeassistant/components/komfovent/__init__.py b/homeassistant/components/komfovent/__init__.py new file mode 100644 index 00000000000..0366a429b21 --- /dev/null +++ b/homeassistant/components/komfovent/__init__.py @@ -0,0 +1,34 @@ +"""The Komfovent integration.""" +from __future__ import annotations + +import komfovent_api + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Komfovent from a config entry.""" + host = entry.data[CONF_HOST] + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + _, credentials = komfovent_api.get_credentials(host, username, password) + result, settings = await komfovent_api.get_settings(credentials) + if result != komfovent_api.KomfoventConnectionResult.SUCCESS: + raise ConfigEntryNotReady(f"Unable to connect to {host}: {result}") + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (credentials, settings) + + 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/komfovent/climate.py b/homeassistant/components/komfovent/climate.py new file mode 100644 index 00000000000..2e51fddf4f2 --- /dev/null +++ b/homeassistant/components/komfovent/climate.py @@ -0,0 +1,91 @@ +"""Ventilation Units from Komfovent integration.""" +from __future__ import annotations + +import komfovent_api + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +HASS_TO_KOMFOVENT_MODES = { + HVACMode.COOL: komfovent_api.KomfoventModes.COOL, + HVACMode.HEAT_COOL: komfovent_api.KomfoventModes.HEAT_COOL, + HVACMode.OFF: komfovent_api.KomfoventModes.OFF, + HVACMode.AUTO: komfovent_api.KomfoventModes.AUTO, +} +KOMFOVENT_TO_HASS_MODES = {v: k for k, v in HASS_TO_KOMFOVENT_MODES.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Komfovent unit control.""" + credentials, settings = hass.data[DOMAIN][entry.entry_id] + async_add_entities([KomfoventDevice(credentials, settings)], True) + + +class KomfoventDevice(ClimateEntity): + """Representation of a ventilation unit.""" + + _attr_hvac_modes = list(HASS_TO_KOMFOVENT_MODES.keys()) + _attr_preset_modes = [mode.name for mode in komfovent_api.KomfoventPresets] + _attr_supported_features = ClimateEntityFeature.PRESET_MODE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + credentials: komfovent_api.KomfoventCredentials, + settings: komfovent_api.KomfoventSettings, + ) -> None: + """Initialize the ventilation unit.""" + self._komfovent_credentials = credentials + self._komfovent_settings = settings + + self._attr_unique_id = settings.serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, settings.serial_number)}, + model=settings.model, + name=settings.name, + serial_number=settings.serial_number, + sw_version=settings.version, + manufacturer="Komfovent", + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + await komfovent_api.set_preset( + self._komfovent_credentials, + komfovent_api.KomfoventPresets[preset_mode], + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await komfovent_api.set_mode( + self._komfovent_credentials, HASS_TO_KOMFOVENT_MODES[hvac_mode] + ) + + async def async_update(self) -> None: + """Get the latest data.""" + result, status = await komfovent_api.get_unit_status( + self._komfovent_credentials + ) + if result != komfovent_api.KomfoventConnectionResult.SUCCESS or not status: + self._attr_available = False + return + self._attr_available = True + self._attr_preset_mode = status.preset + self._attr_current_temperature = status.temp_extract + self._attr_hvac_mode = KOMFOVENT_TO_HASS_MODES[status.mode] diff --git a/homeassistant/components/komfovent/config_flow.py b/homeassistant/components/komfovent/config_flow.py new file mode 100644 index 00000000000..fb5390a30c6 --- /dev/null +++ b/homeassistant/components/komfovent/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Komfovent integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import komfovent_api +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER = "user" +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_USERNAME, default="user"): str, + vol.Required(CONF_PASSWORD): str, + } +) + +ERRORS_MAP = { + komfovent_api.KomfoventConnectionResult.NOT_FOUND: "cannot_connect", + komfovent_api.KomfoventConnectionResult.UNAUTHORISED: "invalid_auth", + komfovent_api.KomfoventConnectionResult.INVALID_INPUT: "invalid_input", +} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Komfovent.""" + + VERSION = 1 + + def __return_error( + self, result: komfovent_api.KomfoventConnectionResult + ) -> FlowResult: + return self.async_show_form( + step_id=STEP_USER, + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": ERRORS_MAP.get(result, "unknown")}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id=STEP_USER, data_schema=STEP_USER_DATA_SCHEMA + ) + + conf_host = user_input[CONF_HOST] + conf_username = user_input[CONF_USERNAME] + conf_password = user_input[CONF_PASSWORD] + + result, credentials = komfovent_api.get_credentials( + conf_host, conf_username, conf_password + ) + if result != komfovent_api.KomfoventConnectionResult.SUCCESS: + return self.__return_error(result) + + result, settings = await komfovent_api.get_settings(credentials) + if result != komfovent_api.KomfoventConnectionResult.SUCCESS: + return self.__return_error(result) + + await self.async_set_unique_id(settings.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=settings.name, data=user_input) diff --git a/homeassistant/components/komfovent/const.py b/homeassistant/components/komfovent/const.py new file mode 100644 index 00000000000..a7881a58c41 --- /dev/null +++ b/homeassistant/components/komfovent/const.py @@ -0,0 +1,3 @@ +"""Constants for the Komfovent integration.""" + +DOMAIN = "komfovent" diff --git a/homeassistant/components/komfovent/manifest.json b/homeassistant/components/komfovent/manifest.json new file mode 100644 index 00000000000..cbe00ef8dc5 --- /dev/null +++ b/homeassistant/components/komfovent/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "komfovent", + "name": "Komfovent", + "codeowners": ["@ProstoSanja"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/komfovent", + "iot_class": "local_polling", + "requirements": ["komfovent-api==0.0.3"] +} diff --git a/homeassistant/components/komfovent/strings.json b/homeassistant/components/komfovent/strings.json new file mode 100644 index 00000000000..074754c1fe0 --- /dev/null +++ b/homeassistant/components/komfovent/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_input": "Failed to parse provided hostname", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b0327dbdc29..16f0e48e4ee 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -244,6 +244,7 @@ FLOWS = { "kmtronic", "knx", "kodi", + "komfovent", "konnected", "kostal_plenticore", "kraken", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e6ceda10924..7680463cbd2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2881,6 +2881,12 @@ "config_flow": true, "iot_class": "local_push" }, + "komfovent": { + "name": "Komfovent", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "konnected": { "name": "Konnected.io", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 6624e9c2594..4f9d614f4c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,6 +1127,9 @@ kiwiki-client==0.1.1 # homeassistant.components.knx knx-frontend==2023.6.23.191712 +# homeassistant.components.komfovent +komfovent-api==0.0.3 + # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f20e579e47..6b5cb6a53ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -886,6 +886,9 @@ kegtron-ble==0.4.0 # homeassistant.components.knx knx-frontend==2023.6.23.191712 +# homeassistant.components.komfovent +komfovent-api==0.0.3 + # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/komfovent/__init__.py b/tests/components/komfovent/__init__.py new file mode 100644 index 00000000000..e5492a52327 --- /dev/null +++ b/tests/components/komfovent/__init__.py @@ -0,0 +1 @@ +"""Tests for the Komfovent integration.""" diff --git a/tests/components/komfovent/conftest.py b/tests/components/komfovent/conftest.py new file mode 100644 index 00000000000..d9cb0950c74 --- /dev/null +++ b/tests/components/komfovent/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Komfovent tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.komfovent.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/komfovent/test_config_flow.py b/tests/components/komfovent/test_config_flow.py new file mode 100644 index 00000000000..008d92e36a3 --- /dev/null +++ b/tests/components/komfovent/test_config_flow.py @@ -0,0 +1,189 @@ +"""Test the Komfovent config flow.""" +from unittest.mock import AsyncMock, patch + +import komfovent_api +import pytest + +from homeassistant import config_entries +from homeassistant.components.komfovent.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test flow completes as expected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + final_result = await __test_normal_flow(hass, result["flow_id"]) + assert final_result["type"] == FlowResultType.CREATE_ENTRY + assert final_result["title"] == "test-name" + assert final_result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "expected_response"), + [ + (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), + (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), + (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), + ], +) +async def test_flow_error_authenticating( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + error: komfovent_api.KomfoventConnectionResult, + expected_response: str, +) -> None: + """Test errors during flow authentication step are handled and dont affect final result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", + return_value=( + error, + None, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_response} + + final_result = await __test_normal_flow(hass, result2["flow_id"]) + assert final_result["type"] == FlowResultType.CREATE_ENTRY + assert final_result["title"] == "test-name" + assert final_result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "expected_response"), + [ + (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), + (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), + (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), + ], +) +async def test_flow_error_device_info( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + error: komfovent_api.KomfoventConnectionResult, + expected_response: str, +) -> None: + """Test errors during flow device info download step are handled and dont affect final result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", + return_value=( + komfovent_api.KomfoventConnectionResult.SUCCESS, + komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), + ), + ), patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", + return_value=( + error, + None, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_response} + + final_result = await __test_normal_flow(hass, result2["flow_id"]) + assert final_result["type"] == FlowResultType.CREATE_ENTRY + assert final_result["title"] == "test-name" + assert final_result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_device_already_exists( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test device is not added when it already exists.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + unique_id="test-uid", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + final_result = await __test_normal_flow(hass, result["flow_id"]) + assert final_result["type"] == FlowResultType.ABORT + assert final_result["reason"] == "already_configured" + + +async def __test_normal_flow(hass: HomeAssistant, flow_id: str) -> FlowResult: + """Test flow completing as expected, no matter what happened before.""" + + with patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", + return_value=( + komfovent_api.KomfoventConnectionResult.SUCCESS, + komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), + ), + ), patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", + return_value=( + komfovent_api.KomfoventConnectionResult.SUCCESS, + komfovent_api.KomfoventSettings("test-name", None, None, "test-uid"), + ), + ): + final_result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + return final_result From 048b989b2bc3da86fb6036485644c5f9f6d821a2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 11 Nov 2023 03:57:05 -0800 Subject: [PATCH 387/982] Bump ical to 6.1.0 (#103759) --- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/xmpp/manifest.json | 2 +- requirements_all.txt | 5 ++++- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index d21048c191c..d7b16ee3bef 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==6.0.0"] + "requirements": ["ical==6.1.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index cf2a49f6510..4c3a8e10a62 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==6.0.0"] + "requirements": ["ical==6.1.0"] } diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index 99b3ff126d3..30dee6c842b 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/xmpp", "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], - "requirements": ["slixmpp==1.8.4"] + "requirements": ["slixmpp==1.8.4", "emoji==2.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f9d614f4c2..ed2b34af829 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -748,6 +748,9 @@ elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 +# homeassistant.components.xmpp +emoji==2.8.0 + # homeassistant.components.emulated_roku emulated-roku==0.2.1 @@ -1053,7 +1056,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.0.0 +ical==6.1.0 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b5cb6a53ed..680b34b3147 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -833,7 +833,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.0.0 +ical==6.1.0 # homeassistant.components.ping icmplib==3.0 From bf41167951e7edf11f2ba2b74651b5108b4c4e82 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 11 Nov 2023 14:24:23 +0100 Subject: [PATCH 388/982] Fix typo in calendar translation (#103789) --- homeassistant/components/calendar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 81334c12379..20679ed09b2 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -24,7 +24,7 @@ "location": { "name": "Location" }, - "messages": { + "message": { "name": "Message" }, "start_time": { From c35f56ea7770e33090d219c40b51868878c8055e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 11 Nov 2023 22:26:37 +0100 Subject: [PATCH 389/982] Handle BaseException from asyncio gather (#103814) --- homeassistant/components/elkm1/discovery.py | 2 ++ homeassistant/components/notion/__init__.py | 2 ++ homeassistant/components/wiz/discovery.py | 2 ++ homeassistant/helpers/service.py | 6 +++--- homeassistant/helpers/trigger.py | 4 +++- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/elkm1/discovery.py b/homeassistant/components/elkm1/discovery.py index 50db2840753..83b2d3f113b 100644 --- a/homeassistant/components/elkm1/discovery.py +++ b/homeassistant/components/elkm1/discovery.py @@ -63,6 +63,8 @@ async def async_discover_devices( if isinstance(discovered, Exception): _LOGGER.debug("Scanning %s failed with error: %s", targets[idx], discovered) continue + if isinstance(discovered, BaseException): + raise discovered from None for device in discovered: assert isinstance(device, ElkSystem) combined_discoveries[device.ip_address] = device diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 88605fdbdfd..b8881fdf56d 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -189,6 +189,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed( f"There was an unknown error while updating {attr}: {result}" ) from result + if isinstance(result, BaseException): + raise result from None data.update_data_from_response(result) diff --git a/homeassistant/components/wiz/discovery.py b/homeassistant/components/wiz/discovery.py index 0f4be1d873e..350ddfe278a 100644 --- a/homeassistant/components/wiz/discovery.py +++ b/homeassistant/components/wiz/discovery.py @@ -33,6 +33,8 @@ async def async_discover_devices( if isinstance(discovered, Exception): _LOGGER.debug("Scanning %s failed with error: %s", targets[idx], discovered) continue + if isinstance(discovered, BaseException): + raise discovered from None for device in discovered: assert isinstance(device, DiscoveredBulb) combined_discoveries[device.ip_address] = device diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 4cb8852414b..3c6bf4436eb 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -885,7 +885,7 @@ async def entity_service_call( # Use asyncio.gather here to ensure the returned results # are in the same order as the entities list - results: list[ServiceResponse] = await asyncio.gather( + results: list[ServiceResponse | BaseException] = await asyncio.gather( *[ entity.async_request_call( _handle_entity_call(hass, entity, func, data, call.context) @@ -897,8 +897,8 @@ async def entity_service_call( response_data: EntityServiceResponse = {} for entity, result in zip(entities, results): - if isinstance(result, Exception): - raise result + if isinstance(result, BaseException): + raise result from None response_data[entity.entity_id] = result tasks: list[asyncio.Task[None]] = [] diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 40e1860b409..c5cfdadabb2 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -305,7 +305,7 @@ async def async_initialize_triggers( variables: TemplateVarsType = None, ) -> CALLBACK_TYPE | None: """Initialize triggers.""" - triggers = [] + triggers: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] for idx, conf in enumerate(trigger_config): # Skip triggers that are not enabled if not conf.get(CONF_ENABLED, True): @@ -338,6 +338,8 @@ async def async_initialize_triggers( log_cb(logging.ERROR, f"Got error '{result}' when setting up triggers for") elif isinstance(result, Exception): log_cb(logging.ERROR, "Error setting up trigger", exc_info=result) + elif isinstance(result, BaseException): + raise result from None elif result is None: log_cb( logging.ERROR, "Unknown error while setting up trigger (empty result)" From 66d1a7f1dd6d32d89d63af053cbaffa38114843f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 11 Nov 2023 22:30:55 +0100 Subject: [PATCH 390/982] Update ReadOnlyEntityOptions typing (#103813) --- homeassistant/components/homeassistant/exposed_entities.py | 6 ++++-- homeassistant/helpers/entity_registry.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 07f14e7ce8c..16a7ee5009c 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.storage import Store +from homeassistant.util.read_only_dict import ReadOnlyDict from .const import DATA_EXPOSED_ENTITIES, DOMAIN @@ -145,7 +146,7 @@ class ExposedEntities: assistant, entity_id, key, value ) - assistant_options: Mapping[str, Any] + assistant_options: ReadOnlyDict[str, Any] | dict[str, Any] if ( assistant_options := registry_entry.options.get(assistant, {}) ) and assistant_options.get(key) == value: @@ -256,7 +257,8 @@ class ExposedEntities: else: should_expose = False - assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {}) + assistant_options: ReadOnlyDict[str, Any] | dict[str, Any] + assistant_options = registry_entry.options.get(assistant, {}) assistant_options = assistant_options | {"should_expose": should_expose} entity_registry.async_update_entity_options( entity_id, assistant, assistant_options diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index a97e283af07..65ae1a8e9e5 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -131,7 +131,7 @@ EventEntityRegistryUpdatedData = ( EntityOptionsType = Mapping[str, Mapping[str, Any]] -ReadOnlyEntityOptionsType = ReadOnlyDict[str, Mapping[str, Any]] +ReadOnlyEntityOptionsType = ReadOnlyDict[str, ReadOnlyDict[str, Any]] DISLAY_DICT_OPTIONAL = ( ("ai", "area_id"), From a70ec64408b234ea0ffb5d3842893955235e171e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 11 Nov 2023 23:31:04 +0100 Subject: [PATCH 391/982] Update mypy to 1.7.0 (#103800) --- homeassistant/bootstrap.py | 2 +- homeassistant/components/assist_pipeline/select.py | 4 +--- homeassistant/components/bond/__init__.py | 4 ++-- homeassistant/components/diagnostics/util.py | 2 +- homeassistant/components/logbook/queries/all.py | 11 +++-------- homeassistant/components/netgear_lte/__init__.py | 2 +- homeassistant/components/notion/__init__.py | 2 +- homeassistant/components/overkiz/__init__.py | 6 +++++- homeassistant/components/overkiz/coordinator.py | 2 +- homeassistant/components/recorder/statistics.py | 5 +---- .../recorder/table_managers/statistics_meta.py | 5 +---- homeassistant/components/sensor/recorder.py | 14 ++------------ homeassistant/components/tplink_lte/__init__.py | 2 +- homeassistant/helpers/device_registry.py | 11 ++--------- homeassistant/helpers/template.py | 4 ++-- homeassistant/helpers/trigger.py | 2 +- requirements_test.txt | 2 +- 17 files changed, 27 insertions(+), 53 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 098f970d55f..b9bb638e052 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -398,7 +398,7 @@ def async_enable_logging( logging.getLogger("httpx").setLevel(logging.WARNING) sys.excepthook = lambda *args: logging.getLogger(None).exception( - "Uncaught exception", exc_info=args # type: ignore[arg-type] + "Uncaught exception", exc_info=args ) threading.excepthook = lambda args: logging.getLogger(None).exception( "Uncaught thread exception", diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 2ae46fcb9ac..83e1bd3ab36 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -93,9 +93,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): if self.registry_entry and (device_id := self.registry_entry.device_id): pipeline_data.pipeline_devices.add(device_id) self.async_on_remove( - lambda: pipeline_data.pipeline_devices.discard( - device_id # type: ignore[arg-type] - ) + lambda: pipeline_data.pipeline_devices.discard(device_id) ) async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 46066d9f55e..b6f402004f6 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -126,10 +126,10 @@ async def async_remove_config_entry_device( for identifier in device_entry.identifiers: if identifier[0] != DOMAIN or len(identifier) != 3: continue - bond_id: str = identifier[1] + bond_id: str = identifier[1] # type: ignore[unreachable] # Bond still uses the 3 arg tuple before # the identifiers were typed - device_id: str = identifier[2] # type: ignore[misc] + device_id: str = identifier[2] # If device_id is no longer present on # the hub, we allow removal. if hub.bond_id != bond_id or not any( diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index cbb8831e9b5..47a0eac9a0d 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -12,7 +12,7 @@ _T = TypeVar("_T") @overload -def async_redact_data(data: Mapping, to_redact: Iterable[Any]) -> dict: # type: ignore[misc] +def async_redact_data(data: Mapping, to_redact: Iterable[Any]) -> dict: # type: ignore[overload-overlap] ... diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index c6196687ac2..21f88135a1d 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -28,18 +28,13 @@ def all_stmt( ) if context_id_bin is not None: stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( - _states_query_for_context_id( - start_day, - end_day, - # https://github.com/python/mypy/issues/2608 - context_id_bin, # type:ignore[arg-type] - ), + _states_query_for_context_id(start_day, end_day, context_id_bin), ) elif filters and filters.has_config: stmt = stmt.add_criteria( - lambda q: q.filter(filters.events_entity_filter()).union_all( # type: ignore[union-attr] + lambda q: q.filter(filters.events_entity_filter()).union_all( _states_query_for_all(start_day, end_day).where( - filters.states_metadata_entity_filter() # type: ignore[union-attr] + filters.states_metadata_entity_filter() ) ), track_on=[filters], diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index ed9ee49a0de..d6ce3cb0994 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -124,7 +124,7 @@ class LTEData: """Shared state.""" websession = attr.ib() - modem_data = attr.ib(init=False, factory=dict) + modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) def get_modem_data(self, config): """Get modem_data for the host in config.""" diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index b8881fdf56d..036ef6e4f0e 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -192,7 +192,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if isinstance(result, BaseException): raise result from None - data.update_data_from_response(result) + data.update_data_from_response(result) # type: ignore[arg-type] return data diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index d3fdda07f74..36713d972b1 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict from dataclasses import dataclass +from typing import cast from aiohttp import ClientError from pyoverkiz.client import OverkizClient @@ -15,7 +16,7 @@ from pyoverkiz.exceptions import ( NotSuchTokenException, TooManyRequestsException, ) -from pyoverkiz.models import Device, Scenario +from pyoverkiz.models import Device, Scenario, Setup from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -77,6 +78,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except MaintenanceException as exception: raise ConfigEntryNotReady("Server is down for maintenance") from exception + setup = cast(Setup, setup) + scenarios = cast(list[Scenario], scenarios) + coordinator = OverkizDataUpdateCoordinator( hass, LOGGER, diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 7c9cab5f181..e5079b3d3b8 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -43,7 +43,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): name: str, client: OverkizClient, devices: list[Device], - places: Place, + places: Place | None, update_interval: timedelta | None = None, config_entry_id: str, ) -> None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index a6fe7ddb22f..78c475753a2 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1088,10 +1088,7 @@ def _generate_statistics_during_period_stmt( end_time_ts = end_time.timestamp() stmt += lambda q: q.filter(table.start_ts < end_time_ts) if metadata_ids: - stmt += lambda q: q.filter( - # https://github.com/python/mypy/issues/2608 - table.metadata_id.in_(metadata_ids) # type:ignore[arg-type] - ) + stmt += lambda q: q.filter(table.metadata_id.in_(metadata_ids)) stmt += lambda q: q.order_by(table.metadata_id, table.start_ts) return stmt diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index 75af59d7c7a..a484bdf145e 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -41,10 +41,7 @@ def _generate_get_metadata_stmt( """Generate a statement to fetch metadata.""" stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) if statistic_ids: - stmt += lambda q: q.where( - # https://github.com/python/mypy/issues/2608 - StatisticsMeta.statistic_id.in_(statistic_ids) # type:ignore[arg-type] - ) + stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids)) if statistic_source is not None: stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) if statistic_type == "mean": diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 3cf1dc975ec..d08a20636ab 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -497,19 +497,9 @@ def compile_statistics( # noqa: C901 # Make calculations stat: StatisticData = {"start": start} if "max" in wanted_statistics[entity_id]: - stat["max"] = max( - *itertools.islice( - zip(*valid_float_states), # type: ignore[typeddict-item] - 1, - ) - ) + stat["max"] = max(*itertools.islice(zip(*valid_float_states), 1)) if "min" in wanted_statistics[entity_id]: - stat["min"] = min( - *itertools.islice( - zip(*valid_float_states), # type: ignore[typeddict-item] - 1, - ) - ) + stat["min"] = min(*itertools.islice(zip(*valid_float_states), 1)) if "mean" in wanted_statistics[entity_id]: stat["mean"] = _time_weighted_average(valid_float_states, start, end) diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py index 52ee5dfd980..378fd0a35d4 100644 --- a/homeassistant/components/tplink_lte/__init__.py +++ b/homeassistant/components/tplink_lte/__init__.py @@ -70,7 +70,7 @@ class LTEData: """Shared state.""" websession = attr.ib() - modem_data = attr.ib(init=False, factory=dict) + modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) def get_modem_data(self, config): """Get the requested or the only modem_data value.""" diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 48ebd7b6ebc..9a26821faaf 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -823,15 +823,8 @@ class DeviceRegistry: for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( config_entries=set(device["config_entries"]), - # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 - connections={ - tuple(conn) # type: ignore[misc] - for conn in device["connections"] - }, - identifiers={ - tuple(iden) # type: ignore[misc] - for iden in device["identifiers"] - }, + connections={tuple(conn) for conn in device["connections"]}, + identifiers={tuple(iden) for iden in device["identifiers"]}, id=device["id"], orphaned_timestamp=device["orphaned_timestamp"], ) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index fa165da1772..721ac8bd5be 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2567,7 +2567,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["expand"] = hassfunction(expand) self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = hassfunction(closest_filter) # type: ignore[arg-type] + self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) self.tests["is_hidden_entity"] = hassfunction( @@ -2608,7 +2608,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return super().is_safe_attribute(obj, attr, value) @overload - def compile( # type: ignore[misc] + def compile( # type: ignore[overload-overlap] self, source: str | jinja2.nodes.Template, name: str | None = None, diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index c5cfdadabb2..a4391061899 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -341,7 +341,7 @@ async def async_initialize_triggers( elif isinstance(result, BaseException): raise result from None elif result is None: - log_cb( + log_cb( # type: ignore[unreachable] logging.ERROR, "Unknown error while setting up trigger (empty result)" ) else: diff --git a/requirements_test.txt b/requirements_test.txt index a13ab170086..bc88a59fc8e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==3.0.1 coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.6.1 +mypy==1.7.0 pre-commit==3.5.0 pydantic==1.10.12 pylint==3.0.2 From 1d5fcfc7c8eb3c2c6025a8fe654cb3bac7ea1acb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 11 Nov 2023 15:14:08 -0800 Subject: [PATCH 392/982] Improve local calendar based on local todo review feedback (#103483) * Improve local calendar based on local todo review feedback * Revert fakestore change to diagnose timeout * Revert init changes * Revert and add assert --- .../components/local_calendar/__init__.py | 23 ++++++++-- .../components/local_calendar/config_flow.py | 6 ++- .../components/local_calendar/const.py | 1 + tests/components/local_calendar/conftest.py | 37 +++++++++++----- .../local_calendar/test_config_flow.py | 33 ++++++++++++++- tests/components/local_calendar/test_init.py | 42 +++++++++++++++++++ 6 files changed, 125 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index 7c1d2f09b04..3b302742ab6 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -7,9 +7,10 @@ from pathlib import Path from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, DOMAIN +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -24,9 +25,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Local Calendar from a config entry.""" hass.data.setdefault(DOMAIN, {}) - key = slugify(entry.data[CONF_CALENDAR_NAME]) - path = Path(hass.config.path(STORAGE_PATH.format(key=key))) - hass.data[DOMAIN][entry.entry_id] = LocalCalendarStore(hass, path) + if CONF_STORAGE_KEY not in entry.data: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_STORAGE_KEY: slugify(entry.data[CONF_CALENDAR_NAME]), + }, + ) + + path = Path(hass.config.path(STORAGE_PATH.format(key=entry.data[CONF_STORAGE_KEY]))) + store = LocalCalendarStore(hass, path) + try: + await store.async_load() + except OSError as err: + raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + + hass.data[DOMAIN][entry.entry_id] = store await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index 2bde06820b6..a5a75fee58b 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -7,8 +7,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, DOMAIN +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -31,6 +32,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) + key = slugify(user_input[CONF_CALENDAR_NAME]) + self._async_abort_entries_match({CONF_STORAGE_KEY: key}) + user_input[CONF_STORAGE_KEY] = key return self.async_create_entry( title=user_input[CONF_CALENDAR_NAME], data=user_input ) diff --git a/homeassistant/components/local_calendar/const.py b/homeassistant/components/local_calendar/const.py index 49cd5dc22a4..1cfa774ab0a 100644 --- a/homeassistant/components/local_calendar/const.py +++ b/homeassistant/components/local_calendar/const.py @@ -3,3 +3,4 @@ DOMAIN = "local_calendar" CONF_CALENDAR_NAME = "calendar_name" +CONF_STORAGE_KEY = "storage_key" diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index 7dc294087bd..8455fc2f34f 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Generator from http import HTTPStatus from pathlib import Path from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import urllib from aiohttp import ClientWebSocketResponse @@ -20,24 +20,31 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator CALENDAR_NAME = "Light Schedule" FRIENDLY_NAME = "Light schedule" +STORAGE_KEY = "light_schedule" TEST_ENTITY = "calendar.light_schedule" class FakeStore(LocalCalendarStore): """Mock storage implementation.""" - def __init__(self, hass: HomeAssistant, path: Path, ics_content: str) -> None: + def __init__( + self, hass: HomeAssistant, path: Path, ics_content: str, read_side_effect: Any + ) -> None: """Initialize FakeStore.""" super().__init__(hass, path) - self._content = ics_content + mock_path = self._mock_path = Mock() + mock_path.exists = self._mock_exists + mock_path.read_text = Mock() + mock_path.read_text.return_value = ics_content + mock_path.read_text.side_effect = read_side_effect + mock_path.write_text = self._mock_write_text + super().__init__(hass, mock_path) - def _load(self) -> str: - """Read from calendar storage.""" - return self._content + def _mock_exists(self) -> bool: + return self._mock_path.read_text.return_value is not None - def _store(self, ics_content: str) -> None: - """Persist the calendar storage.""" - self._content = ics_content + def _mock_write_text(self, content: str) -> None: + self._mock_path.read_text.return_value = content @pytest.fixture(name="ics_content", autouse=True) @@ -46,15 +53,23 @@ def mock_ics_content() -> str: return "" +@pytest.fixture(name="store_read_side_effect") +def mock_store_read_side_effect() -> Any | None: + """Fixture to raise errors from the FakeStore.""" + return None + + @pytest.fixture(name="store", autouse=True) -def mock_store(ics_content: str) -> Generator[None, None, None]: +def mock_store( + ics_content: str, store_read_side_effect: Any | None +) -> Generator[None, None, None]: """Test cleanup, remove any media storage persisted during the test.""" stores: dict[Path, FakeStore] = {} def new_store(hass: HomeAssistant, path: Path) -> FakeStore: if path not in stores: - stores[path] = FakeStore(hass, path, ics_content) + stores[path] = FakeStore(hass, path, ics_content, store_read_side_effect) return stores[path] with patch( diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 25049326762..6cebd42cf30 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -2,10 +2,16 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.local_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.components.local_calendar.const import ( + CONF_CALENDAR_NAME, + CONF_STORAGE_KEY, + DOMAIN, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -31,5 +37,30 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["title"] == "My Calendar" assert result2["data"] == { CONF_CALENDAR_NAME: "My Calendar", + CONF_STORAGE_KEY: "my_calendar", } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_name( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test two calendars cannot be added with the same name.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + # Pick a name that has the same slugify value as an existing config entry + CONF_CALENDAR_NAME: "light schedule", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/local_calendar/test_init.py b/tests/components/local_calendar/test_init.py index e5ca209e8a6..8e79cccea36 100644 --- a/tests/components/local_calendar/test_init.py +++ b/tests/components/local_calendar/test_init.py @@ -2,11 +2,36 @@ from unittest.mock import patch +import pytest + +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from .conftest import TEST_ENTITY + from tests.common import MockConfigEntry +async def test_load_unload( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test loading and unloading a config entry.""" + + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "off" + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "unavailable" + + async def test_remove_config_entry( hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry ) -> None: @@ -16,3 +41,20 @@ async def test_remove_config_entry( assert await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() unlink_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("store_read_side_effect"), + [ + (OSError("read error")), + ], +) +async def test_load_failure( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test failures loading the store.""" + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + state = hass.states.get(TEST_ENTITY) + assert not state From 48a8ae4df526d71e8b2e8ec7eb99de2766db59d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Nov 2023 18:27:49 -0600 Subject: [PATCH 393/982] Bump aioesphomeapi to 18.4.0 (#103817) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9b375610a99..3b5a2050cb8 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.3.0", + "aioesphomeapi==18.4.0", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index ed2b34af829..bbb3f176ef4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.3.0 +aioesphomeapi==18.4.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 680b34b3147..39d9f70c3f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.3.0 +aioesphomeapi==18.4.0 # homeassistant.components.flo aioflo==2021.11.0 From 25650563fe33b9fda3fcef180cb3e23d1c5cde1b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 11 Nov 2023 23:36:30 -0800 Subject: [PATCH 394/982] Fix Rainbird unique to use a more reliable source (mac address) (#101603) * Fix rainbird unique id to use a mac address for new entries * Fix typo * Normalize mac address before using as unique id * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Update test check and remove dead code * Update all config entries to the new format * Update config entry tests for migration * Fix rainbird entity unique ids * Add test coverage for repair failure * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove unnecessary migration failure checks * Remove invalid config entries * Update entry when entering a different hostname for an existing host. --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/rainbird/__init__.py | 88 +++++++- .../components/rainbird/config_flow.py | 45 ++-- tests/components/rainbird/conftest.py | 51 ++++- .../components/rainbird/test_binary_sensor.py | 49 ++-- tests/components/rainbird/test_calendar.py | 16 +- tests/components/rainbird/test_config_flow.py | 79 ++++++- tests/components/rainbird/test_init.py | 212 +++++++++++++++++- tests/components/rainbird/test_number.py | 57 ++--- tests/components/rainbird/test_sensor.py | 32 ++- tests/components/rainbird/test_switch.py | 47 ++-- 10 files changed, 515 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index a97af14f449..e7a7c1200b9 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,17 +1,25 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations +import logging + from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController from pyrainbird.exceptions import RainbirdApiException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_registry import async_entries_for_config_entry +from .const import CONF_SERIAL_NUMBER from .coordinator import RainbirdData +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [ Platform.SWITCH, Platform.SENSOR, @@ -36,6 +44,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) ) + + if not (await _async_fix_unique_id(hass, controller, entry)): + return False + if mac_address := entry.data.get(CONF_MAC): + _async_fix_entity_unique_id( + hass, + er.async_get(hass), + entry.entry_id, + format_mac(mac_address), + str(entry.data[CONF_SERIAL_NUMBER]), + ) + try: model_info = await controller.get_model_and_version() except RainbirdApiException as err: @@ -51,6 +71,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def _async_fix_unique_id( + hass: HomeAssistant, controller: AsyncRainbirdController, entry: ConfigEntry +) -> bool: + """Update the config entry with a unique id based on the mac address.""" + _LOGGER.debug("Checking for migration of config entry (%s)", entry.unique_id) + if not (mac_address := entry.data.get(CONF_MAC)): + try: + wifi_params = await controller.get_wifi_params() + except RainbirdApiException as err: + _LOGGER.warning("Unable to fix missing unique id: %s", err) + return True + + if (mac_address := wifi_params.mac_address) is None: + _LOGGER.warning("Unable to fix missing unique id (mac address was None)") + return True + + new_unique_id = format_mac(mac_address) + if entry.unique_id == new_unique_id and CONF_MAC in entry.data: + _LOGGER.debug("Config entry already in correct state") + return True + + entries = hass.config_entries.async_entries(DOMAIN) + for existing_entry in entries: + if existing_entry.unique_id == new_unique_id: + _LOGGER.warning( + "Unable to fix missing unique id (already exists); Removing duplicate entry" + ) + hass.async_create_background_task( + hass.config_entries.async_remove(entry.entry_id), + "Remove rainbird config entry", + ) + return False + + _LOGGER.debug("Updating unique id to %s", new_unique_id) + hass.config_entries.async_update_entry( + entry, + unique_id=new_unique_id, + data={ + **entry.data, + CONF_MAC: mac_address, + }, + ) + return True + + +def _async_fix_entity_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_id: str, + mac_address: str, + serial_number: str, +) -> None: + """Migrate existing entity if current one can't be found and an old one exists.""" + entity_entries = async_entries_for_config_entry(entity_registry, config_entry_id) + for entity_entry in entity_entries: + unique_id = str(entity_entry.unique_id) + if unique_id.startswith(mac_address): + continue + if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: + new_unique_id = f"{mac_address}{suffix}" + _LOGGER.debug("Updating unique id from %s to %s", unique_id, new_unique_id) + entity_registry.async_update_entity( + entity_entry.entity_id, new_unique_id=new_unique_id + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index bf6682e7a6f..f90e13d37f3 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -11,15 +11,17 @@ from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, ) +from pyrainbird.data import WifiParams import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from .const import ( ATTR_DURATION, @@ -69,7 +71,7 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): error_code: str | None = None if user_input: try: - serial_number = await self._test_connection( + serial_number, wifi_params = await self._test_connection( user_input[CONF_HOST], user_input[CONF_PASSWORD] ) except ConfigFlowError as err: @@ -77,11 +79,11 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): error_code = err.error_code else: return await self.async_finish( - serial_number, data={ CONF_HOST: user_input[CONF_HOST], CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_SERIAL_NUMBER: serial_number, + CONF_MAC: wifi_params.mac_address, }, options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, ) @@ -92,8 +94,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": error_code} if error_code else None, ) - async def _test_connection(self, host: str, password: str) -> str: - """Test the connection and return the device serial number. + async def _test_connection( + self, host: str, password: str + ) -> tuple[str, WifiParams]: + """Test the connection and return the device identifiers. Raises a ConfigFlowError on failure. """ @@ -106,7 +110,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) try: async with asyncio.timeout(TIMEOUT_SECONDS): - return await controller.get_serial_number() + return await asyncio.gather( + controller.get_serial_number(), + controller.get_wifi_params(), + ) except asyncio.TimeoutError as err: raise ConfigFlowError( f"Timeout connecting to Rain Bird controller: {str(err)}", @@ -120,18 +127,28 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_finish( self, - serial_number: str, data: dict[str, Any], options: dict[str, Any], ) -> FlowResult: """Create the config entry.""" - # Prevent devices with the same serial number. If the device does not have a serial number - # then we can at least prevent configuring the same host twice. - if serial_number: - await self.async_set_unique_id(serial_number) - self._abort_if_unique_id_configured() - else: - self._async_abort_entries_match(data) + # The integration has historically used a serial number, but not all devices + # historically had a valid one. Now the mac address is used as a unique id + # and serial is still persisted in config entry data in case it is needed + # in the future. + # Either way, also prevent configuring the same host twice. + await self.async_set_unique_id(format_mac(data[CONF_MAC])) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: data[CONF_HOST], + CONF_PASSWORD: data[CONF_PASSWORD], + } + ) + self._async_abort_entries_match( + { + CONF_HOST: data[CONF_HOST], + CONF_PASSWORD: data[CONF_PASSWORD], + } + ) return self.async_create_entry( title=data[CONF_HOST], data=data, diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 6e8d58219c1..52b98e5c6b6 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from http import HTTPStatus +import json from typing import Any from unittest.mock import patch @@ -24,6 +25,8 @@ HOST = "example.com" URL = "http://example.com/stick" PASSWORD = "password" SERIAL_NUMBER = 0x12635436566 +MAC_ADDRESS = "4C:A1:61:00:11:22" +MAC_ADDRESS_UNIQUE_ID = "4c:a1:61:00:11:22" # # Response payloads below come from pyrainbird test cases. @@ -50,6 +53,20 @@ RAIN_DELAY = "B60010" # 0x10 is 16 RAIN_DELAY_OFF = "B60000" # ACK command 0x10, Echo 0x06 ACK_ECHO = "0106" +WIFI_PARAMS_RESPONSE = { + "macAddress": MAC_ADDRESS, + "localIpAddress": "1.1.1.38", + "localNetmask": "255.255.255.0", + "localGateway": "1.1.1.1", + "rssi": -61, + "wifiSsid": "wifi-ssid-name", + "wifiPassword": "wifi-password-name", + "wifiSecurity": "wpa2-aes", + "apTimeoutNoLan": 20, + "apTimeoutIdle": 20, + "apSecurity": "unknown", + "stickVersion": "Rain Bird Stick Rev C/1.63", +} CONFIG = { @@ -62,10 +79,16 @@ CONFIG = { } } +CONFIG_ENTRY_DATA_OLD_FORMAT = { + "host": HOST, + "password": PASSWORD, + "serial_number": SERIAL_NUMBER, +} CONFIG_ENTRY_DATA = { "host": HOST, "password": PASSWORD, "serial_number": SERIAL_NUMBER, + "mac": MAC_ADDRESS, } @@ -77,14 +100,23 @@ def platforms() -> list[Platform]: @pytest.fixture async def config_entry_unique_id() -> str: - """Fixture for serial number used in the config entry.""" + """Fixture for config entry unique id.""" + return MAC_ADDRESS_UNIQUE_ID + + +@pytest.fixture +async def serial_number() -> int: + """Fixture for serial number used in the config entry data.""" return SERIAL_NUMBER @pytest.fixture -async def config_entry_data() -> dict[str, Any]: +async def config_entry_data(serial_number: int) -> dict[str, Any]: """Fixture for MockConfigEntry data.""" - return CONFIG_ENTRY_DATA + return { + **CONFIG_ENTRY_DATA, + "serial_number": serial_number, + } @pytest.fixture @@ -123,17 +155,24 @@ def setup_platforms( yield -def rainbird_response(data: str) -> bytes: +def rainbird_json_response(result: dict[str, str]) -> bytes: """Create a fake API response.""" return encryption.encrypt( - '{"jsonrpc": "2.0", "result": {"data":"%s"}, "id": 1} ' % data, + '{"jsonrpc": "2.0", "result": %s, "id": 1} ' % json.dumps(result), PASSWORD, ) +def mock_json_response(result: dict[str, str]) -> AiohttpClientMockResponse: + """Create a fake AiohttpClientMockResponse.""" + return AiohttpClientMockResponse( + "POST", URL, response=rainbird_json_response(result) + ) + + def mock_response(data: str) -> AiohttpClientMockResponse: """Create a fake AiohttpClientMockResponse.""" - return AiohttpClientMockResponse("POST", URL, response=rainbird_response(data)) + return mock_json_response({"data": data}) def mock_response_error( diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index 7b9fb41ed1f..afe18337377 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -1,6 +1,8 @@ """Tests for rainbird sensor platform.""" +from http import HTTPStatus + import pytest from homeassistant.config_entries import ConfigEntryState @@ -8,7 +10,12 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, SERIAL_NUMBER +from .conftest import ( + CONFIG_ENTRY_DATA_OLD_FORMAT, + RAIN_SENSOR_OFF, + RAIN_SENSOR_ON, + mock_response_error, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -51,47 +58,25 @@ async def test_rainsensor( @pytest.mark.parametrize( - ("config_entry_unique_id", "entity_unique_id"), + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), [ - (SERIAL_NUMBER, "1263613994342-rainsensor"), - # Some existing config entries may have a "0" serial number but preserve - # their unique id - (0, "0-rainsensor"), - ], -) -async def test_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - entity_unique_id: str, -) -> None: - """Test rainsensor binary sensor.""" - rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") - assert rainsensor is not None - assert rainsensor.attributes == { - "friendly_name": "Rain Bird Controller Rainsensor", - "icon": "mdi:water", - } - - entity_entry = entity_registry.async_get( - "binary_sensor.rain_bird_controller_rainsensor" - ) - assert entity_entry - assert entity_entry.unique_id == entity_unique_id - - -@pytest.mark.parametrize( - ("config_entry_unique_id"), - [ - (None), + (CONFIG_ENTRY_DATA_OLD_FORMAT, None, None), ], ) async def test_no_unique_id( hass: HomeAssistant, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test rainsensor binary sensor with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert ( diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index d6c14834342..04e423a399c 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -17,7 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import mock_response, mock_response_error +from .conftest import CONFIG_ENTRY_DATA_OLD_FORMAT, mock_response, mock_response_error from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -210,7 +210,7 @@ async def test_event_state( entity = entity_registry.async_get(TEST_ENTITY) assert entity - assert entity.unique_id == 1263613994342 + assert entity.unique_id == "4c:a1:61:00:11:22" @pytest.mark.parametrize( @@ -280,18 +280,26 @@ async def test_program_schedule_disabled( @pytest.mark.parametrize( - ("config_entry_unique_id"), + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), [ - (None), + (CONFIG_ENTRY_DATA_OLD_FORMAT, None, None), ], ) async def test_no_unique_id( hass: HomeAssistant, get_events: GetEventsFn, + responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test calendar entity with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(TEST_ENTITY) assert state is not None assert state.attributes.get("friendly_name") == "Rain Bird Controller" diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index f93da8d9839..6c0e13fef39 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -19,11 +19,14 @@ from homeassistant.data_entry_flow import FlowResult, FlowResultType from .conftest import ( CONFIG_ENTRY_DATA, HOST, + MAC_ADDRESS_UNIQUE_ID, PASSWORD, SERIAL_NUMBER, SERIAL_RESPONSE, URL, + WIFI_PARAMS_RESPONSE, ZERO_SERIAL_RESPONSE, + mock_json_response, mock_response, ) @@ -34,7 +37,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockRespon @pytest.fixture(name="responses") def mock_responses() -> list[AiohttpClientMockResponse]: """Set up fake serial number response when testing the connection.""" - return [mock_response(SERIAL_RESPONSE)] + return [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)] @pytest.fixture(autouse=True) @@ -74,14 +77,20 @@ async def complete_flow(hass: HomeAssistant) -> FlowResult: ("responses", "expected_config_entry", "expected_unique_id"), [ ( - [mock_response(SERIAL_RESPONSE)], + [ + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], CONFIG_ENTRY_DATA, - SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, ), ( - [mock_response(ZERO_SERIAL_RESPONSE)], + [ + mock_response(ZERO_SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], {**CONFIG_ENTRY_DATA, "serial_number": 0}, - None, + MAC_ADDRESS_UNIQUE_ID, ), ], ) @@ -115,17 +124,32 @@ async def test_controller_flow( ( "other-serial-number", {**CONFIG_ENTRY_DATA, "host": "other-host"}, - [mock_response(SERIAL_RESPONSE)], + [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)], + CONFIG_ENTRY_DATA, + ), + ( + "11:22:33:44:55:66", + { + **CONFIG_ENTRY_DATA, + "host": "other-host", + }, + [ + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], CONFIG_ENTRY_DATA, ), ( None, {**CONFIG_ENTRY_DATA, "serial_number": 0, "host": "other-host"}, - [mock_response(ZERO_SERIAL_RESPONSE)], + [ + mock_response(ZERO_SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], {**CONFIG_ENTRY_DATA, "serial_number": 0}, ), ], - ids=["with-serial", "zero-serial"], + ids=["with-serial", "with-mac-address", "zero-serial"], ) async def test_multiple_config_entries( hass: HomeAssistant, @@ -154,22 +178,52 @@ async def test_multiple_config_entries( "config_entry_unique_id", "config_entry_data", "config_flow_responses", + "expected_config_entry_data", ), [ + # Config entry is a pure duplicate with the same mac address unique id + ( + MAC_ADDRESS_UNIQUE_ID, + CONFIG_ENTRY_DATA, + [ + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], + CONFIG_ENTRY_DATA, + ), + # Old unique id with serial, but same host ( SERIAL_NUMBER, CONFIG_ENTRY_DATA, - [mock_response(SERIAL_RESPONSE)], + [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)], + CONFIG_ENTRY_DATA, ), + # Old unique id with no serial, but same host ( None, {**CONFIG_ENTRY_DATA, "serial_number": 0}, - [mock_response(ZERO_SERIAL_RESPONSE)], + [ + mock_response(ZERO_SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], + {**CONFIG_ENTRY_DATA, "serial_number": 0}, + ), + # Enters a different hostname that points to the same mac address + ( + MAC_ADDRESS_UNIQUE_ID, + { + **CONFIG_ENTRY_DATA, + "host": f"other-{HOST}", + }, + [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)], + CONFIG_ENTRY_DATA, # Updated the host ), ], ids=[ - "duplicate-serial-number", + "duplicate-mac-unique-id", + "duplicate-host-legacy-serial-number", "duplicate-host-port-no-serial", + "duplicate-duplicate-hostname", ], ) async def test_duplicate_config_entries( @@ -177,6 +231,7 @@ async def test_duplicate_config_entries( config_entry: MockConfigEntry, responses: list[AiohttpClientMockResponse], config_flow_responses: list[AiohttpClientMockResponse], + expected_config_entry_data: dict[str, Any], ) -> None: """Test that a device can not be registered twice.""" await config_entry.async_setup(hass) @@ -186,8 +241,10 @@ async def test_duplicate_config_entries( responses.extend(config_flow_responses) result = await complete_flow(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "already_configured" + assert dict(config_entry.data) == expected_config_entry_data async def test_controller_cannot_connect( diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 7ec22b88867..db9c4c8739e 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -6,12 +6,21 @@ from http import HTTPStatus import pytest +from homeassistant.components.rainbird.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import ( CONFIG_ENTRY_DATA, + CONFIG_ENTRY_DATA_OLD_FORMAT, + MAC_ADDRESS, + MAC_ADDRESS_UNIQUE_ID, MODEL_AND_VERSION_RESPONSE, + SERIAL_NUMBER, + WIFI_PARAMS_RESPONSE, + mock_json_response, mock_response, mock_response_error, ) @@ -20,22 +29,11 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse -@pytest.mark.parametrize( - ("config_entry_data", "initial_response"), - [ - (CONFIG_ENTRY_DATA, None), - ], - ids=["config_entry"], -) async def test_init_success( hass: HomeAssistant, config_entry: MockConfigEntry, - responses: list[AiohttpClientMockResponse], - initial_response: AiohttpClientMockResponse | None, ) -> None: """Test successful setup and unload.""" - if initial_response: - responses.insert(0, initial_response) await config_entry.async_setup(hass) assert config_entry.state == ConfigEntryState.LOADED @@ -88,6 +86,196 @@ async def test_communication_failure( config_entry_state: list[ConfigEntryState], ) -> None: """Test unable to talk to device on startup, which fails setup.""" - await config_entry.async_setup(hass) assert config_entry.state == config_entry_state + + +@pytest.mark.parametrize( + ("config_entry_unique_id", "config_entry_data"), + [ + ( + None, + {**CONFIG_ENTRY_DATA, "mac": None}, + ), + ], + ids=["config_entry"], +) +async def test_fix_unique_id( + hass: HomeAssistant, + responses: list[AiohttpClientMockResponse], + config_entry: MockConfigEntry, +) -> None: + """Test fix of a config entry with no unique id.""" + + responses.insert(0, mock_json_response(WIFI_PARAMS_RESPONSE)) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.NOT_LOADED + assert entries[0].unique_id is None + assert entries[0].data.get(CONF_MAC) is None + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + # Verify config entry now has a unique id + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + assert entries[0].unique_id == MAC_ADDRESS_UNIQUE_ID + assert entries[0].data.get(CONF_MAC) == MAC_ADDRESS + + +@pytest.mark.parametrize( + ( + "config_entry_unique_id", + "config_entry_data", + "initial_response", + "expected_warning", + ), + [ + ( + None, + CONFIG_ENTRY_DATA_OLD_FORMAT, + mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), + "Unable to fix missing unique id:", + ), + ( + None, + CONFIG_ENTRY_DATA_OLD_FORMAT, + mock_response_error(HTTPStatus.NOT_FOUND), + "Unable to fix missing unique id:", + ), + ( + None, + CONFIG_ENTRY_DATA_OLD_FORMAT, + mock_response("bogus"), + "Unable to fix missing unique id (mac address was None)", + ), + ], + ids=["service_unavailable", "not_found", "unexpected_response_format"], +) +async def test_fix_unique_id_failure( + hass: HomeAssistant, + initial_response: AiohttpClientMockResponse, + responses: list[AiohttpClientMockResponse], + expected_warning: str, + caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, +) -> None: + """Test a failure during fix of a config entry with no unique id.""" + + responses.insert(0, initial_response) + + await config_entry.async_setup(hass) + # Config entry is loaded, but not updated + assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.unique_id is None + + assert expected_warning in caplog.text + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [(MAC_ADDRESS_UNIQUE_ID)], +) +async def test_fix_unique_id_duplicate( + hass: HomeAssistant, + config_entry: MockConfigEntry, + responses: list[AiohttpClientMockResponse], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a config entry unique id already exists during fix.""" + # Add a second config entry that has no unique id, but has the same + # mac address. When fixing the unique id, it can't use the mac address + # since it already exists. + other_entry = MockConfigEntry( + unique_id=None, + domain=DOMAIN, + data=CONFIG_ENTRY_DATA_OLD_FORMAT, + ) + other_entry.add_to_hass(hass) + + # Responses for the second config entry. This first fetches wifi params + # to repair the unique id. + responses_copy = [*responses] + responses.append(mock_json_response(WIFI_PARAMS_RESPONSE)) + responses.extend(responses_copy) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.unique_id == MAC_ADDRESS_UNIQUE_ID + + await other_entry.async_setup(hass) + # Config entry unique id could not be updated since it already exists + assert other_entry.state == ConfigEntryState.SETUP_ERROR + + assert "Unable to fix missing unique id (already exists)" in caplog.text + + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.parametrize( + ( + "config_entry_unique_id", + "serial_number", + "entity_unique_id", + "expected_unique_id", + ), + [ + (SERIAL_NUMBER, SERIAL_NUMBER, SERIAL_NUMBER, MAC_ADDRESS_UNIQUE_ID), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + f"{SERIAL_NUMBER}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + ), + ("0", 0, "0", MAC_ADDRESS_UNIQUE_ID), + ( + "0", + 0, + "0-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + ), + ( + MAC_ADDRESS_UNIQUE_ID, + SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), + ( + MAC_ADDRESS_UNIQUE_ID, + SERIAL_NUMBER, + f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + ), + ], + ids=( + "serial-number", + "serial-number-with-suffix", + "zero-serial", + "zero-serial-suffix", + "new-format", + "new-format-suffx", + ), +) +async def test_fix_entity_unique_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_unique_id: str, + expected_unique_id: str, +) -> None: + """Test fixing entity unique ids from old unique id formats.""" + + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, "number", unique_id=entity_unique_id, config_entry=config_entry + ) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + entity_entry = entity_registry.async_get(entity_entry.id) + assert entity_entry + assert entity_entry.unique_id == expected_unique_id diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 0beae1f5a95..79b8fd5ec37 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components import number from homeassistant.components.rainbird import DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -14,15 +14,16 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( ACK_ECHO, + CONFIG_ENTRY_DATA_OLD_FORMAT, + MAC_ADDRESS, RAIN_DELAY, RAIN_DELAY_OFF, - SERIAL_NUMBER, mock_response, mock_response_error, ) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @pytest.fixture @@ -66,46 +67,23 @@ async def test_number_values( entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") assert entity_entry - assert entity_entry.unique_id == "1263613994342-rain-delay" - - -@pytest.mark.parametrize( - ("config_entry_unique_id", "entity_unique_id"), - [ - (SERIAL_NUMBER, "1263613994342-rain-delay"), - # Some existing config entries may have a "0" serial number but preserve - # their unique id - (0, "0-rain-delay"), - ], -) -async def test_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - entity_unique_id: str, -) -> None: - """Test number platform.""" - - raindelay = hass.states.get("number.rain_bird_controller_rain_delay") - assert raindelay is not None - assert ( - raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay" - ) - - entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") - assert entity_entry - assert entity_entry.unique_id == entity_unique_id + assert entity_entry.unique_id == "4c:a1:61:00:11:22-rain-delay" async def test_set_value( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, responses: list[str], - config_entry: ConfigEntry, ) -> None: """Test setting the rain delay number.""" + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") + assert raindelay is not None + device_registry = dr.async_get(hass) - device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, MAC_ADDRESS.lower())} + ) assert device assert device.name == "Rain Bird Controller" assert device.model == "ESP-TM2" @@ -138,7 +116,6 @@ async def test_set_value_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, responses: list[str], - config_entry: ConfigEntry, status: HTTPStatus, expected_msg: str, ) -> None: @@ -162,17 +139,25 @@ async def test_set_value_error( @pytest.mark.parametrize( - ("config_entry_unique_id"), + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), [ - (None), + (CONFIG_ENTRY_DATA_OLD_FORMAT, None, None), ], ) async def test_no_unique_id( hass: HomeAssistant, + responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test number platform with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None assert ( diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 00d778335c5..2a0195f8d97 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -1,5 +1,6 @@ """Tests for rainbird sensor platform.""" +from http import HTTPStatus import pytest @@ -8,9 +9,15 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF +from .conftest import ( + CONFIG_ENTRY_DATA_OLD_FORMAT, + RAIN_DELAY, + RAIN_DELAY_OFF, + mock_response_error, +) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMockResponse @pytest.fixture @@ -49,37 +56,38 @@ async def test_sensors( entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") assert entity_entry - assert entity_entry.unique_id == "1263613994342-raindelay" + assert entity_entry.unique_id == "4c:a1:61:00:11:22-raindelay" @pytest.mark.parametrize( - ("config_entry_unique_id", "config_entry_data"), + ("config_entry_unique_id", "config_entry_data", "setup_config_entry"), [ # Config entry setup without a unique id since it had no serial number ( None, { - **CONFIG_ENTRY_DATA, - "serial_number": 0, - }, - ), - # Legacy case for old config entries with serial number 0 preserves old behavior - ( - "0", - { - **CONFIG_ENTRY_DATA, + **CONFIG_ENTRY_DATA_OLD_FORMAT, "serial_number": 0, }, + None, ), ], ) async def test_sensor_no_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, + responses: list[AiohttpClientMockResponse], config_entry_unique_id: str | None, + config_entry: MockConfigEntry, ) -> None: """Test sensor platform with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") assert raindelay is not None assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay" diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index e2b6a99d01a..f9c03f63dd3 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -13,12 +13,13 @@ from homeassistant.helpers import entity_registry as er from .conftest import ( ACK_ECHO, + CONFIG_ENTRY_DATA_OLD_FORMAT, EMPTY_STATIONS_RESPONSE, HOST, + MAC_ADDRESS, PASSWORD, RAIN_DELAY_OFF, RAIN_SENSOR_OFF, - SERIAL_NUMBER, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, @@ -109,7 +110,7 @@ async def test_zones( # Verify unique id for one of the switches entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") - assert entity_entry.unique_id == "1263613994342-3" + assert entity_entry.unique_id == "4c:a1:61:00:11:22-3" async def test_switch_on( @@ -226,6 +227,7 @@ async def test_irrigation_service( "1": "Garden Sprinkler", "2": "Back Yard", }, + "mac": MAC_ADDRESS, } ) ], @@ -274,9 +276,9 @@ async def test_switch_error( @pytest.mark.parametrize( - ("config_entry_unique_id"), + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), [ - (None), + (CONFIG_ENTRY_DATA_OLD_FORMAT, None, None), ], ) async def test_no_unique_id( @@ -284,8 +286,15 @@ async def test_no_unique_id( aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: - """Test an irrigation switch with no unique id.""" + """Test an irrigation switch with no unique id due to migration failure.""" + + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None @@ -294,31 +303,3 @@ async def test_no_unique_id( entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") assert entity_entry is None - - -@pytest.mark.parametrize( - ("config_entry_unique_id", "entity_unique_id"), - [ - (SERIAL_NUMBER, "1263613994342-3"), - # Some existing config entries may have a "0" serial number but preserve - # their unique id - (0, "0-3"), - ], -) -async def test_has_unique_id( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - responses: list[AiohttpClientMockResponse], - entity_registry: er.EntityRegistry, - entity_unique_id: str, -) -> None: - """Test an irrigation switch with no unique id.""" - - zone = hass.states.get("switch.rain_bird_sprinkler_3") - assert zone is not None - assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" - assert zone.state == "off" - - entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") - assert entity_entry - assert entity_entry.unique_id == entity_unique_id From 492d8acf3f163da2a7292f4f0785e602d114dec6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Nov 2023 02:32:14 -0600 Subject: [PATCH 395/982] Bump zeroconf to 0.123.0 (#103830) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.122.3...0.123.0 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index df763e7db8b..00f81be0793 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.122.3"] + "requirements": ["zeroconf==0.123.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d9b0418b77e..188fe02698b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -56,7 +56,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.122.3 +zeroconf==0.123.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index bbb3f176ef4..88631b18cc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2803,7 +2803,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.122.3 +zeroconf==0.123.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39d9f70c3f3..58d9095b837 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2094,7 +2094,7 @@ yt-dlp==2023.10.13 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.122.3 +zeroconf==0.123.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 2cffb4df6d53339fb71c5affd149ff1d7657c958 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Nov 2023 02:33:48 -0600 Subject: [PATCH 396/982] Bump pyunifiprotect to 4.21.0 (#103832) changelog: https://github.com/AngellusMortis/pyunifiprotect/compare/v4.20.0...v4.21.0 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b63700720e6..ee6f6d05548 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.20.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.21.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 88631b18cc7..039740f7ba7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2241,7 +2241,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.20.0 +pyunifiprotect==4.21.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58d9095b837..fbdbf86fc94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1673,7 +1673,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.20.0 +pyunifiprotect==4.21.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 81450f01172e003eed2c4332a433b8c1bb0b36cd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 12 Nov 2023 10:38:32 +0100 Subject: [PATCH 397/982] Update d-e* tests to use entity & device registry fixtures (#103804) --- tests/components/daikin/test_init.py | 19 +++--- tests/components/derivative/test_init.py | 6 +- tests/components/derivative/test_sensor.py | 9 +-- .../device_tracker/test_config_entry.py | 61 ++++++++++--------- .../device_tracker/test_entities.py | 6 +- .../devolo_home_control/test_init.py | 2 +- tests/components/dhcp/test_init.py | 9 +-- tests/components/directv/test_media_player.py | 6 +- tests/components/directv/test_remote.py | 6 +- tests/components/dlink/test_init.py | 5 +- .../components/dremel_3d_printer/test_init.py | 6 +- tests/components/dsmr/test_init.py | 2 +- tests/components/dsmr/test_sensor.py | 20 +++--- tests/components/dynalite/common.py | 3 - tests/components/easyenergy/test_sensor.py | 21 ++++--- tests/components/efergy/test_init.py | 5 +- tests/components/efergy/test_sensor.py | 10 +-- tests/components/energy/test_sensor.py | 20 +++--- tests/components/enocean/test_switch.py | 15 +++-- tests/components/esphome/test_entry_data.py | 23 +++---- 20 files changed, 139 insertions(+), 115 deletions(-) diff --git a/tests/components/daikin/test_init.py b/tests/components/daikin/test_init.py index 3b5f81ae2e5..857d9e399f4 100644 --- a/tests/components/daikin/test_init.py +++ b/tests/components/daikin/test_init.py @@ -50,7 +50,12 @@ DATA = { INVALID_DATA = {**DATA, "name": None, "mac": HOST} -async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: +async def test_duplicate_removal( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_daikin, +) -> None: """Test duplicate device removal.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -59,8 +64,6 @@ async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: data={CONF_HOST: HOST, KEY_MAC: HOST}, ) config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) type(mock_daikin).mac = PropertyMock(return_value=HOST) type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA) @@ -111,7 +114,12 @@ async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) -async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: +async def test_unique_id_migrate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_daikin, +) -> None: """Test unique id migration.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -120,8 +128,6 @@ async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: data={CONF_HOST: HOST, KEY_MAC: HOST}, ) config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) type(mock_daikin).mac = PropertyMock(return_value=HOST) type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA) @@ -171,7 +177,6 @@ async def test_client_update_connection_error( data={CONF_HOST: HOST, KEY_MAC: MAC}, ) config_entry.add_to_hass(hass) - er.async_get(hass) type(mock_daikin).mac = PropertyMock(return_value=MAC) type(mock_daikin).values = PropertyMock(return_value=DATA) diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index fef13109007..eab8ca67be7 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -11,11 +11,11 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("platform", ("sensor",)) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, platform: str, ) -> None: """Test setting up and removing a config entry.""" input_sensor_entity_id = "sensor.input" - registry = er.async_get(hass) derivative_entity_id = f"{platform}.my_derivative" # Setup the config entry @@ -37,7 +37,7 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(derivative_entity_id) is not None + assert entity_registry.async_get(derivative_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(derivative_entity_id) @@ -58,4 +58,4 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(derivative_entity_id) is None - assert registry.async_get(derivative_entity_id) is None + assert entity_registry.async_get(derivative_entity_id) is None diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 5ba00cabd9d..4d954fcbb43 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -348,11 +348,12 @@ async def test_suffix(hass: HomeAssistant) -> None: assert round(float(state.state), config["sensor"]["round"]) == 0.0 -async def test_device_id(hass: HomeAssistant) -> None: +async def test_device_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test for source entity device for Derivative.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - source_config_entry = MockConfigEntry() source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index f9c259a00f4..e55a9b5b6b2 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -25,33 +25,34 @@ def test_tracker_entity() -> None: async def test_cleanup_legacy( - hass: HomeAssistant, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + enable_custom_integrations: None, ) -> None: """Test we clean up devices created by old device tracker.""" - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) - device1 = dev_reg.async_get_or_create( + device1 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device1")} ) - device2 = dev_reg.async_get_or_create( + device2 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device2")} ) - device3 = dev_reg.async_get_or_create( + device3 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device3")} ) # Device with light + device tracker entity - entity1a = ent_reg.async_get_or_create( + entity1a = entity_registry.async_get_or_create( DOMAIN, "test", "entity1a-unique", config_entry=config_entry, device_id=device1.id, ) - entity1b = ent_reg.async_get_or_create( + entity1b = entity_registry.async_get_or_create( "light", "test", "entity1b-unique", @@ -59,7 +60,7 @@ async def test_cleanup_legacy( device_id=device1.id, ) # Just device tracker entity - entity2a = ent_reg.async_get_or_create( + entity2a = entity_registry.async_get_or_create( DOMAIN, "test", "entity2a-unique", @@ -67,7 +68,7 @@ async def test_cleanup_legacy( device_id=device2.id, ) # Device with no device tracker entities - entity3a = ent_reg.async_get_or_create( + entity3a = entity_registry.async_get_or_create( "light", "test", "entity3a-unique", @@ -75,14 +76,14 @@ async def test_cleanup_legacy( device_id=device3.id, ) # Device tracker but no device - entity4a = ent_reg.async_get_or_create( + entity4a = entity_registry.async_get_or_create( DOMAIN, "test", "entity4a-unique", config_entry=config_entry, ) # Completely different entity - entity5a = ent_reg.async_get_or_create( + entity5a = entity_registry.async_get_or_create( "light", "test", "entity4a-unique", @@ -93,25 +94,26 @@ async def test_cleanup_legacy( await hass.async_block_till_done() for entity in (entity1a, entity1b, entity3a, entity4a, entity5a): - assert ent_reg.async_get(entity.entity_id) is not None + assert entity_registry.async_get(entity.entity_id) is not None # We've removed device so device ID cleared - assert ent_reg.async_get(entity2a.entity_id).device_id is None + assert entity_registry.async_get(entity2a.entity_id).device_id is None # Removed because only had device tracker entity - assert dev_reg.async_get(device2.id) is None + assert device_registry.async_get(device2.id) is None -async def test_register_mac(hass: HomeAssistant) -> None: +async def test_register_mac( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test registering a mac.""" - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) mac1 = "12:34:56:AB:CD:EF" - entity_entry_1 = ent_reg.async_get_or_create( + entity_entry_1 = entity_registry.async_get_or_create( "device_tracker", "test", mac1 + "yo1", @@ -122,29 +124,30 @@ async def test_register_mac(hass: HomeAssistant) -> None: ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") - dev_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, ) await hass.async_block_till_done() - entity_entry_1 = ent_reg.async_get(entity_entry_1.entity_id) + entity_entry_1 = entity_registry.async_get(entity_entry_1.entity_id) assert entity_entry_1.disabled_by is None -async def test_register_mac_ignored(hass: HomeAssistant) -> None: +async def test_register_mac_ignored( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test ignoring registering a mac.""" - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - config_entry = MockConfigEntry(domain="test", pref_disable_new_entities=True) config_entry.add_to_hass(hass) mac1 = "12:34:56:AB:CD:EF" - entity_entry_1 = ent_reg.async_get_or_create( + entity_entry_1 = entity_registry.async_get_or_create( "device_tracker", "test", mac1 + "yo1", @@ -155,14 +158,14 @@ async def test_register_mac_ignored(hass: HomeAssistant) -> None: ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") - dev_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, ) await hass.async_block_till_done() - entity_entry_1 = ent_reg.async_get(entity_entry_1.entity_id) + entity_entry_1 = entity_registry.async_get(entity_entry_1.entity_id) assert entity_entry_1.disabled_by == er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/device_tracker/test_entities.py b/tests/components/device_tracker/test_entities.py index 960f9c18b08..45f1b21c89a 100644 --- a/tests/components/device_tracker/test_entities.py +++ b/tests/components/device_tracker/test_entities.py @@ -21,13 +21,15 @@ from tests.common import MockConfigEntry async def test_scanner_entity_device_tracker( - hass: HomeAssistant, enable_custom_integrations: None + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + enable_custom_integrations: None, ) -> None: """Test ScannerEntity based device tracker.""" # Make device tied to other integration so device tracker entities get enabled other_config_entry = MockConfigEntry(domain="not_fake_integration") other_config_entry.add_to_hass(hass) - dr.async_get(hass).async_get_or_create( + device_registry.async_get_or_create( name="Device from other integration", config_entry_id=other_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "ad:de:ef:be:ed:fe")}, diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 29572f2ece4..cb4c87aebdc 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -65,6 +65,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_remove_device( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test removing a device.""" assert await async_setup_component(hass, "config", {}) @@ -77,7 +78,6 @@ async def test_remove_device( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "Test")}) assert device_entry diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 076138080cc..47933c30537 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -213,7 +213,9 @@ async def test_dhcp_renewal_match_hostname_and_macaddress(hass: HomeAssistant) - ) -async def test_registered_devices(hass: HomeAssistant) -> None: +async def test_registered_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test discovery flows are created for registered devices.""" integration_matchers = [ {"domain": "not-matching", "registered_devices": True}, @@ -222,10 +224,9 @@ async def test_registered_devices(hass: HomeAssistant) -> None: packet = Ether(RAW_DHCP_RENEWAL) - registry = dr.async_get(hass) config_entry = MockConfigEntry(domain="mock-domain", data={}) config_entry.add_to_hass(hass) - registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "50147903852c")}, name="name", @@ -233,7 +234,7 @@ async def test_registered_devices(hass: HomeAssistant) -> None: # Not enabled should not get flows config_entry2 = MockConfigEntry(domain="mock-domain-2", data={}) config_entry2.add_to_hass(hass) - registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "50147903852c")}, name="name", diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 8d11dc6c9d0..5dc76a2170e 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -142,13 +142,13 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - async def test_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test unique id.""" await setup_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - main = entity_registry.async_get(MAIN_ENTITY_ID) assert main.original_device_class == MediaPlayerDeviceClass.RECEIVER assert main.unique_id == "028877455858" diff --git a/tests/components/directv/test_remote.py b/tests/components/directv/test_remote.py index 7a674fefa8c..9d326903933 100644 --- a/tests/components/directv/test_remote.py +++ b/tests/components/directv/test_remote.py @@ -29,13 +29,13 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - async def test_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test unique id.""" await setup_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - main = entity_registry.async_get(MAIN_ENTITY_ID) assert main.unique_id == "028877455858" diff --git a/tests/components/dlink/test_init.py b/tests/components/dlink/test_init.py index dbd4cef0139..4725d0cd3e8 100644 --- a/tests/components/dlink/test_init.py +++ b/tests/components/dlink/test_init.py @@ -59,13 +59,14 @@ async def test_async_setup_entry_not_ready( async def test_device_info( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, ) -> None: """Test device info.""" await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.connections == {("mac", "aa:bb:cc:dd:ee:ff")} diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py index 2740b638316..fa41b74a5d2 100644 --- a/tests/components/dremel_3d_printer/test_init.py +++ b/tests/components/dremel_3d_printer/test_init.py @@ -74,12 +74,14 @@ async def test_update_failed( async def test_device_info( - hass: HomeAssistant, connection, config_entry: MockConfigEntry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + connection, + config_entry: MockConfigEntry, ) -> None: """Test device info.""" await hass.config_entries.async_setup(config_entry.entry_id) assert await async_setup_component(hass, DOMAIN, {}) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DOMAIN, config_entry.unique_id)} ) diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 567df0279b6..512e0822016 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -85,6 +85,7 @@ from tests.common import MockConfigEntry ) async def test_migrate_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], dsmr_version: str, old_unique_id: str, @@ -109,7 +110,6 @@ async def test_migrate_unique_id( mock_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( suggested_object_id="my_sensor", disabled_by=None, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index e7f0e715f59..1895dd15dd1 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -45,7 +45,9 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, patch -async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> None: +async def test_default_setup( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: """Test the default setup.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -102,13 +104,11 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() - registry = er.async_get(hass) - - entry = registry.async_get("sensor.electricity_meter_power_consumption") + entry = entity_registry.async_get("sensor.electricity_meter_power_consumption") assert entry assert entry.unique_id == "1234_current_electricity_usage" - entry = registry.async_get("sensor.gas_meter_gas_consumption") + entry = entity_registry.async_get("sensor.gas_meter_gas_consumption") assert entry assert entry.unique_id == "5678_gas_meter_reading" @@ -184,7 +184,9 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No ) -async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) -> None: +async def test_setup_only_energy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: """Test the default setup.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -232,13 +234,11 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) - # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() - registry = er.async_get(hass) - - entry = registry.async_get("sensor.electricity_meter_power_consumption") + entry = entity_registry.async_get("sensor.electricity_meter_power_consumption") assert entry assert entry.unique_id == "1234_current_electricity_usage" - entry = registry.async_get("sensor.gas_meter_gas_consumption") + entry = entity_registry.async_get("sensor.gas_meter_gas_consumption") assert not entry diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py index 446cdc74c0b..355a1285a56 100644 --- a/tests/components/dynalite/common.py +++ b/tests/components/dynalite/common.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, Mock, call, patch from homeassistant.components import dynalite from homeassistant.const import ATTR_SERVICE -from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -23,8 +22,6 @@ def create_mock_device(platform, spec): async def get_entry_id_from_hass(hass): """Get the config entry id from hass.""" - ent_reg = er.async_get(hass) - assert ent_reg conf_entries = hass.config_entries.async_entries(dynalite.DOMAIN) assert len(conf_entries) == 1 return conf_entries[0].entry_id diff --git a/tests/components/easyenergy/test_sensor.py b/tests/components/easyenergy/test_sensor.py index 98e94197db9..afc3a12d6a2 100644 --- a/tests/components/easyenergy/test_sensor.py +++ b/tests/components/easyenergy/test_sensor.py @@ -32,12 +32,13 @@ from tests.common import MockConfigEntry @pytest.mark.freeze_time("2023-01-19 15:00:00") async def test_energy_usage_today( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, ) -> None: """Test the easyEnergy - Energy usage sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Current usage energy price sensor state = hass.states.get("sensor.easyenergy_today_energy_usage_current_hour_price") @@ -146,12 +147,13 @@ async def test_energy_usage_today( @pytest.mark.freeze_time("2023-01-19 15:00:00") async def test_energy_return_today( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, ) -> None: """Test the easyEnergy - Energy return sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Current return energy price sensor state = hass.states.get("sensor.easyenergy_today_energy_return_current_hour_price") @@ -261,12 +263,13 @@ async def test_energy_return_today( @pytest.mark.freeze_time("2023-01-19 10:00:00") async def test_gas_today( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, ) -> None: """Test the easyEnergy - Gas sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Current gas price sensor state = hass.states.get("sensor.easyenergy_today_gas_current_hour_price") diff --git a/tests/components/efergy/test_init.py b/tests/components/efergy/test_init.py index e82d6615923..df6d6a7b112 100644 --- a/tests/components/efergy/test_init.py +++ b/tests/components/efergy/test_init.py @@ -47,11 +47,12 @@ async def test_async_setup_entry_auth_failed(hass: HomeAssistant) -> None: async def test_device_info( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test device info.""" entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index f8eb889d3c3..45261d45933 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry import homeassistant.util.dt as dt_util from . import MULTI_SENSOR_TOKEN, mock_responses, setup_platform @@ -27,13 +26,14 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_readings( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test for successfully setting up the Efergy platform.""" for description in SENSOR_TYPES: description.entity_registry_enabled_default = True entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) - ent_reg: EntityRegistry = er.async_get(hass) state = hass.states.get("sensor.efergy_power_usage") assert state.state == "1580" @@ -85,9 +85,9 @@ async def test_sensor_readings( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - entity = ent_reg.async_get("sensor.efergy_power_usage_728386") + entity = entity_registry.async_get("sensor.efergy_power_usage_728386") assert entity.disabled_by is er.RegistryEntryDisabler.INTEGRATION - ent_reg.async_update_entity(entity.entity_id, **{"disabled_by": None}) + entity_registry.async_update_entity(entity.entity_id, **{"disabled_by": None}) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.efergy_power_usage_728386") diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index bf1513507f8..f4a1f661f9b 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -88,7 +88,10 @@ async def test_cost_sensor_no_states( async def test_cost_sensor_attributes( - setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any] + setup_integration, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_storage: dict[str, Any], ) -> None: """Test sensor attributes.""" energy_data = data.EnergyManager.default_preferences() @@ -114,9 +117,8 @@ async def test_cost_sensor_attributes( } await setup_integration(hass) - registry = er.async_get(hass) cost_sensor_entity_id = "sensor.energy_consumption_cost" - entry = registry.async_get(cost_sensor_entity_id) + entry = entity_registry.async_get(cost_sensor_entity_id) assert entry.entity_category is None assert entry.disabled_by is None assert entry.hidden_by == er.RegistryEntryHider.INTEGRATION @@ -145,6 +147,7 @@ async def test_cost_sensor_price_entity_total_increasing( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, initial_energy, initial_cost, price_entity, @@ -237,7 +240,6 @@ async def test_cost_sensor_price_entity_total_increasing( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(cost_sensor_entity_id) assert entry postfix = "cost" if flow_type == "flow_from" else "compensation" @@ -357,6 +359,7 @@ async def test_cost_sensor_price_entity_total( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, initial_energy, initial_cost, price_entity, @@ -451,7 +454,6 @@ async def test_cost_sensor_price_entity_total( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(cost_sensor_entity_id) assert entry postfix = "cost" if flow_type == "flow_from" else "compensation" @@ -572,6 +574,7 @@ async def test_cost_sensor_price_entity_total_no_reset( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, initial_energy, initial_cost, price_entity, @@ -665,7 +668,6 @@ async def test_cost_sensor_price_entity_total_no_reset( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(cost_sensor_entity_id) assert entry postfix = "cost" if flow_type == "flow_from" else "compensation" @@ -1156,7 +1158,10 @@ async def test_cost_sensor_state_class_measurement_no_reset( async def test_inherit_source_unique_id( - setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any] + setup_integration, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_storage: dict[str, Any], ) -> None: """Test sensor inherits unique ID from source.""" energy_data = data.EnergyManager.default_preferences() @@ -1175,7 +1180,6 @@ async def test_inherit_source_unique_id( "data": energy_data, } - entity_registry = er.async_get(hass) source_entry = entity_registry.async_get_or_create( "sensor", "test", "123456", suggested_object_id="gas_consumption" ) diff --git a/tests/components/enocean/test_switch.py b/tests/components/enocean/test_switch.py index a7aafa6fc73..4ddd54fba05 100644 --- a/tests/components/enocean/test_switch.py +++ b/tests/components/enocean/test_switch.py @@ -22,7 +22,10 @@ SWITCH_CONFIG = { } -async def test_unique_id_migration(hass: HomeAssistant) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test EnOcean switch ID migration.""" entity_name = SWITCH_CONFIG["switch"][0]["name"] @@ -30,8 +33,6 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: dev_id = SWITCH_CONFIG["switch"][0]["id"] channel = SWITCH_CONFIG["switch"][0]["channel"] - ent_reg = er.async_get(hass) - old_unique_id = f"{combine_hex(dev_id)}" entry = MockConfigEntry(domain=ENOCEAN_DOMAIN, data={"device": "/dev/null"}) @@ -39,7 +40,7 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: entry.add_to_hass(hass) # Add a switch with an old unique_id to the entity registry - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( SWITCH_DOMAIN, ENOCEAN_DOMAIN, old_unique_id, @@ -63,11 +64,13 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check that new entry has a new unique_id - entity_entry = ent_reg.async_get(switch_entity_id) + entity_entry = entity_registry.async_get(switch_entity_id) new_unique_id = f"{combine_hex(dev_id)}-{channel}" assert entity_entry.unique_id == new_unique_id assert ( - ent_reg.async_get_entity_id(SWITCH_DOMAIN, ENOCEAN_DOMAIN, old_unique_id) + entity_registry.async_get_entity_id( + SWITCH_DOMAIN, ENOCEAN_DOMAIN, old_unique_id + ) is None ) diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 64484b91e07..0ba43092d01 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -13,12 +13,12 @@ from homeassistant.helpers import entity_registry as er async def test_migrate_entity_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_client: APIClient, mock_generic_device_entry, ) -> None: """Test a generic sensor entity unique id migration.""" - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "esphome", "my_sensor", @@ -46,10 +46,9 @@ async def test_migrate_entity_unique_id( state = hass.states.get("sensor.old_sensor") assert state is not None assert state.state == "50" - entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.old_sensor") + entry = entity_registry.async_get("sensor.old_sensor") assert entry is not None - assert entity_reg.async_get_entity_id("sensor", "esphome", "my_sensor") is None + assert entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") is None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" @@ -57,19 +56,19 @@ async def test_migrate_entity_unique_id( async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_client: APIClient, mock_generic_device_entry, ) -> None: """Test unique id migration prefers the original entity on downgrade upgrade.""" - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "esphome", "my_sensor", suggested_object_id="old_sensor", disabled_by=None, ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "esphome", "11:22:33:44:55:aa-sensor-mysensor", @@ -97,14 +96,16 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( state = hass.states.get("sensor.new_sensor") assert state is not None assert state.state == "50" - entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.new_sensor") + entry = entity_registry.async_get("sensor.new_sensor") assert entry is not None # Confirm we did not touch the entity that was created # on downgrade so when they upgrade again they can delete the # entity that was only created on downgrade and they keep # the original one. - assert entity_reg.async_get_entity_id("sensor", "esphome", "my_sensor") is not None + assert ( + entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") + is not None + ) # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" From 5a452155fcc2aca564c3852bd3fe7655d8e7cb0c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Nov 2023 11:38:22 +0100 Subject: [PATCH 398/982] Small cleanup in HomeWizard tests (#103837) --- tests/components/homewizard/test_sensor.py | 34 +++++++--------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 52e3eaa8263..d1decb76abf 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -116,6 +116,16 @@ async def test_sensors( "sensor.device_active_frequency", ], ), + ( + "HWE-P1-unused-exports", + [ + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + ], + ), ( "HWE-WTR", [ @@ -136,30 +146,6 @@ async def test_disabled_by_default_sensors( assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION -@pytest.mark.parametrize("device_fixture", ["HWE-P1-unused-exports"]) -@pytest.mark.parametrize( - "entity_id", - [ - "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - ], -) -async def test_disabled_by_default_sensors_when_unused( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - entity_id: str, -) -> None: - """Test the disabled by default unused sensors.""" - assert not hass.states.get(entity_id) - - assert (entry := entity_registry.async_get(entity_id)) - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - @pytest.mark.parametrize("exception", [RequestError, DisabledError]) async def test_sensors_unreachable( hass: HomeAssistant, From 6a7e87f1c3354d011907846c3740c171430cd26d Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Sun, 12 Nov 2023 10:58:15 +0000 Subject: [PATCH 399/982] Add Roon volume hooks (#102470) * Add ability for roon to call HA for volume changes. * Fix merge errors. * Fix mypy errors. * Remove config option for hooks. * WIP split entities. * Tidy, fix test. * Tidy after review. * Remove event tests for now. * Recview comments. * remove trace. * Bump pyroon to 0.1.5, deregister volume hooks. * Remove type annotations. * Add new file .coveragerc. * Remove ghost constants. * Review changes. --------- Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + homeassistant/components/roon/__init__.py | 2 +- homeassistant/components/roon/const.py | 4 +- homeassistant/components/roon/event.py | 109 ++++++++++++++++++++ homeassistant/components/roon/manifest.json | 2 +- homeassistant/components/roon/server.py | 2 +- homeassistant/components/roon/strings.json | 14 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/roon/event.py diff --git a/.coveragerc b/.coveragerc index ebec3974cbb..4aa8ee4949e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1067,6 +1067,7 @@ omit = homeassistant/components/roomba/sensor.py homeassistant/components/roomba/vacuum.py homeassistant/components/roon/__init__.py + homeassistant/components/roon/event.py homeassistant/components/roon/media_browser.py homeassistant/components/roon/media_player.py homeassistant/components/roon/server.py diff --git a/homeassistant/components/roon/__init__.py b/homeassistant/components/roon/__init__.py index 9969b694895..f721f0bac40 100644 --- a/homeassistant/components/roon/__init__.py +++ b/homeassistant/components/roon/__init__.py @@ -7,7 +7,7 @@ from homeassistant.helpers import device_registry as dr from .const import CONF_ROON_NAME, DOMAIN from .server import RoonServer -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/roon/const.py b/homeassistant/components/roon/const.py index 74cf6a38160..01e2aab9685 100644 --- a/homeassistant/components/roon/const.py +++ b/homeassistant/components/roon/const.py @@ -13,8 +13,8 @@ DEFAULT_NAME = "Roon Labs Music Player" ROON_APPINFO = { "extension_id": "home_assistant", - "display_name": "Roon Integration for Home Assistant", - "display_version": "1.0.0", + "display_name": "Home Assistant", + "display_version": "1.0.1", "publisher": "home_assistant", "email": "home_assistant@users.noreply.github.com", "website": "https://www.home-assistant.io/", diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py new file mode 100644 index 00000000000..fc1bb339cd7 --- /dev/null +++ b/homeassistant/components/roon/event.py @@ -0,0 +1,109 @@ +"""Roon event entities.""" +import logging +from typing import cast + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roon Event from Config Entry.""" + roon_server = hass.data[DOMAIN][config_entry.entry_id] + event_entities = set() + + @callback + def async_add_roon_volume_entity(player_data): + """Add or update Roon event Entity.""" + dev_id = player_data["dev_id"] + if dev_id in event_entities: + return + # new player! + event_entity = RoonEventEntity(roon_server, player_data) + event_entities.add(dev_id) + async_add_entities([event_entity]) + + # start listening for players to be added from the server component + config_entry.async_on_unload( + async_dispatcher_connect( + hass, "roon_media_player", async_add_roon_volume_entity + ) + ) + + +class RoonEventEntity(EventEntity): + """Representation of a Roon Event entity.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_event_types = ["volume_up", "volume_down"] + _attr_translation_key = "volume" + + def __init__(self, server, player_data): + """Initialize the entity.""" + self._server = server + self._player_data = player_data + player_name = player_data["display_name"] + self._attr_name = f"{player_name} roon volume" + self._attr_unique_id = self._player_data["dev_id"] + + if self._player_data.get("source_controls"): + dev_model = self._player_data["source_controls"][0].get("display_name") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + # Instead of setting the device name to the entity name, roon + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), + manufacturer="RoonLabs", + model=dev_model, + via_device=(DOMAIN, self._server.roon_id), + ) + + @callback + def _roonapi_volume_callback( + self, control_key: str, event: str, value: int + ) -> None: + """Callbacks from the roon api with volume request.""" + + if event != "set_volume": + _LOGGER.debug("Received unsupported roon volume event %s", event) + return + + if value > 0: + event = "volume_up" + else: + event = "volume_down" + + self._trigger_event(event) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register volume hooks with the roon api.""" + + self._server.roonapi.register_volume_control( + self.unique_id, + self.name, + self._roonapi_volume_callback, + 0, + "incremental", + 0, + 0, + 0, + False, + ) + + async def async_will_remove_from_hass(self) -> None: + """Unregister volume hooks from the roon api.""" + self._server.roonapi.unregister_volume_control(self.unique_id) diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index 4fa527d0769..2598d9e8de1 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roon", "iot_class": "local_push", "loggers": ["roonapi"], - "requirements": ["roonapi==0.1.4"] + "requirements": ["roonapi==0.1.5"] } diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py index 32d909ff00f..488fe18aae4 100644 --- a/homeassistant/components/roon/server.py +++ b/homeassistant/components/roon/server.py @@ -105,7 +105,7 @@ class RoonServer: self._exit = True def roonapi_state_callback(self, event, changed_zones): - """Callbacks from the roon api websockets.""" + """Callbacks from the roon api websocket with state change.""" self.hass.add_job(self.async_update_changed_players(changed_zones)) async def async_do_loop(self): diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json index f67779e9eaa..a95c6908312 100644 --- a/homeassistant/components/roon/strings.json +++ b/homeassistant/components/roon/strings.json @@ -22,6 +22,20 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "event": { + "volume": { + "state_attributes": { + "event_type": { + "state": { + "volume_up": "Volume up", + "volume_down": "Volume down" + } + } + } + } + } + }, "services": { "transfer": { "name": "Transfer", diff --git a/requirements_all.txt b/requirements_all.txt index 039740f7ba7..6d2d8b1ee21 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2361,7 +2361,7 @@ rokuecp==0.18.1 roombapy==1.6.8 # homeassistant.components.roon -roonapi==0.1.4 +roonapi==0.1.5 # homeassistant.components.rova rova==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbdbf86fc94..7f244425a17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1757,7 +1757,7 @@ rokuecp==0.18.1 roombapy==1.6.8 # homeassistant.components.roon -roonapi==0.1.4 +roonapi==0.1.5 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 From b9bc6ca070f07f97f73c14a7b25fe8d06576b003 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 12 Nov 2023 11:04:10 +0000 Subject: [PATCH 400/982] Address late V2C review comments (#103808) * address review * missing fixture --- homeassistant/components/v2c/coordinator.py | 4 ++-- homeassistant/components/v2c/entity.py | 2 +- homeassistant/components/v2c/number.py | 5 ++--- homeassistant/components/v2c/sensor.py | 6 ++---- homeassistant/components/v2c/strings.json | 3 --- homeassistant/components/v2c/switch.py | 5 ++--- tests/components/v2c/test_config_flow.py | 2 +- 7 files changed, 10 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py index 2ab6b967fc6..f61d58b844d 100644 --- a/homeassistant/components/v2c/coordinator.py +++ b/homeassistant/components/v2c/coordinator.py @@ -35,7 +35,7 @@ class V2CUpdateCoordinator(DataUpdateCoordinator[TrydanData]): """Fetch sensor data from api.""" try: data: TrydanData = await self.evse.get_data() - _LOGGER.debug("Received data: %s", data) - return data except TrydanError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + _LOGGER.debug("Received data: %s", data) + return data diff --git a/homeassistant/components/v2c/entity.py b/homeassistant/components/v2c/entity.py index c00e221d397..ee3c94d8d0c 100644 --- a/homeassistant/components/v2c/entity.py +++ b/homeassistant/components/v2c/entity.py @@ -26,7 +26,7 @@ class V2CBaseEntity(CoordinatorEntity[V2CUpdateCoordinator]): super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.evse.host)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="V2C", model="Trydan", name=coordinator.name, diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 843dbbdfa65..0f2551818a2 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -60,11 +60,10 @@ async def async_setup_entry( """Set up V2C Trydan number platform.""" coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities: list[NumberEntity] = [ + async_add_entities( V2CSettingsNumberEntity(coordinator, description, config_entry.entry_id) for description in TRYDAN_NUMBER_SETTINGS - ] - async_add_entities(entities) + ) class V2CSettingsNumberEntity(V2CBaseEntity, NumberEntity): diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 64aacf8e49e..0c860943922 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -90,11 +89,10 @@ async def async_setup_entry( """Set up V2C sensor platform.""" coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities: list[Entity] = [ + async_add_entities( V2CSensorBaseEntity(coordinator, description, config_entry.entry_id) for description in TRYDAN_SENSORS - ] - async_add_entities(entities) + ) class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index 5108b89a58a..749cfb9979e 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -10,9 +10,6 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "entity": { diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index d54c14f88d6..4e56e72dcbf 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -55,11 +55,10 @@ async def async_setup_entry( """Set up V2C switch platform.""" coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities: list[SwitchEntity] = [ + async_add_entities( V2CSwitchEntity(coordinator, description, config_entry.entry_id) for description in TRYDAN_SWITCHES - ] - async_add_entities(entities) + ) class V2CSwitchEntity(V2CBaseEntity, SwitchEntity): diff --git a/tests/components/v2c/test_config_flow.py b/tests/components/v2c/test_config_flow.py index 0124c1abb9c..50bc4ca91bf 100644 --- a/tests/components/v2c/test_config_flow.py +++ b/tests/components/v2c/test_config_flow.py @@ -46,7 +46,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ], ) async def test_form_cannot_connect( - hass: HomeAssistant, side_effect: Exception, error: str + hass: HomeAssistant, mock_setup_entry: AsyncMock, side_effect: Exception, error: str ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( From 04a497343dc8ab3b3333eef07e9a76806e8942cd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 12 Nov 2023 13:07:38 +0100 Subject: [PATCH 401/982] Update f-g* tests to use entity & device registry fixtures (#103841) --- tests/components/fibaro/test_scene.py | 4 +- tests/components/filter/test_sensor.py | 7 ++-- tests/components/fitbit/test_sensor.py | 1 - tests/components/flic/test_binary_sensor.py | 5 ++- tests/components/flux_led/test_init.py | 11 +++--- tests/components/flux_led/test_light.py | 23 +++++++---- tests/components/flux_led/test_number.py | 10 +++-- tests/components/flux_led/test_select.py | 10 +++-- tests/components/flux_led/test_switch.py | 10 +++-- .../components/forecast_solar/test_sensor.py | 13 ++++--- .../freedompro/test_binary_sensor.py | 12 +++--- tests/components/freedompro/test_climate.py | 26 +++++++------ tests/components/freedompro/test_cover.py | 22 +++++------ tests/components/freedompro/test_fan.py | 36 ++++++++++------- tests/components/freedompro/test_light.py | 35 ++++++++++------- tests/components/freedompro/test_lock.py | 29 ++++++++------ tests/components/freedompro/test_sensor.py | 12 ++++-- tests/components/freedompro/test_switch.py | 23 ++++++----- tests/components/fritzbox/test_init.py | 4 +- tests/components/fritzbox/test_sensor.py | 5 ++- tests/components/fritzbox/test_switch.py | 5 ++- tests/components/fronius/test_init.py | 10 +++-- .../fully_kiosk/test_binary_sensor.py | 5 +-- tests/components/fully_kiosk/test_button.py | 5 +-- .../fully_kiosk/test_diagnostics.py | 3 +- tests/components/fully_kiosk/test_init.py | 5 +-- .../fully_kiosk/test_media_player.py | 5 +-- tests/components/fully_kiosk/test_number.py | 5 +-- tests/components/fully_kiosk/test_sensor.py | 5 +-- tests/components/fully_kiosk/test_services.py | 7 ++-- tests/components/fully_kiosk/test_switch.py | 9 +++-- .../components/gardena_bluetooth/test_init.py | 2 +- tests/components/gdacs/test_geo_location.py | 3 +- .../generic_hygrostat/test_humidifier.py | 6 +-- .../generic_thermostat/test_climate.py | 6 +-- tests/components/geofency/test_init.py | 13 ++++--- .../geonetnz_quakes/test_geo_location.py | 3 +- tests/components/gios/test_init.py | 10 ++--- tests/components/gios/test_sensor.py | 39 +++++++++---------- tests/components/github/test_init.py | 2 +- tests/components/glances/test_sensor.py | 12 +++--- tests/components/goalzero/test_init.py | 5 ++- tests/components/google/test_calendar.py | 8 ++-- tests/components/google_mail/test_init.py | 5 ++- tests/components/gpslogger/test_init.py | 13 ++++--- tests/components/group/test_binary_sensor.py | 10 +++-- tests/components/group/test_config_flow.py | 26 +++++++------ tests/components/group/test_cover.py | 5 ++- tests/components/group/test_event.py | 5 ++- tests/components/group/test_fan.py | 5 ++- tests/components/group/test_init.py | 23 +++++------ tests/components/group/test_light.py | 5 ++- tests/components/group/test_lock.py | 5 ++- tests/components/group/test_media_player.py | 5 ++- tests/components/group/test_sensor.py | 4 +- tests/components/group/test_switch.py | 5 ++- 56 files changed, 317 insertions(+), 270 deletions(-) diff --git a/tests/components/fibaro/test_scene.py b/tests/components/fibaro/test_scene.py index 0ce618e903c..e07face3ac0 100644 --- a/tests/components/fibaro/test_scene.py +++ b/tests/components/fibaro/test_scene.py @@ -13,6 +13,7 @@ from tests.common import MockConfigEntry async def test_entity_attributes( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_scene: Mock, @@ -22,7 +23,6 @@ async def test_entity_attributes( # Arrange mock_fibaro_client.read_rooms.return_value = [mock_room] mock_fibaro_client.read_scenes.return_value = [mock_scene] - entity_registry = er.async_get(hass) # Act await init_integration(hass, mock_config_entry) # Assert @@ -35,6 +35,7 @@ async def test_entity_attributes( async def test_entity_attributes_without_room( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_scene: Mock, @@ -45,7 +46,6 @@ async def test_entity_attributes_without_room( mock_room.name = None mock_fibaro_client.read_rooms.return_value = [mock_room] mock_fibaro_client.read_scenes.return_value = [mock_scene] - entity_registry = er.async_get(hass) # Act await init_integration(hass, mock_config_entry) # Assert diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 26df432a270..1f93875a001 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -249,7 +249,9 @@ async def test_history_time(recorder_mock: Recorder, hass: HomeAssistant) -> Non assert state.state == "18.0" -async def test_setup(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_setup( + recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test if filter attributes are inherited.""" config = { "sensor": { @@ -284,8 +286,7 @@ async def test_setup(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING assert state.state == "1.0" - entity_reg = er.async_get(hass) - entity_id = entity_reg.async_get_entity_id( + entity_id = entity_registry.async_get_entity_id( "sensor", DOMAIN, "uniqueid_sensor_test" ) assert entity_id == "sensor.test" diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 9aa6f633e63..08c9761bce2 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -279,7 +279,6 @@ async def test_device_battery( "type": "scale", } - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.aria_air_battery") assert entry assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_016713257" diff --git a/tests/components/flic/test_binary_sensor.py b/tests/components/flic/test_binary_sensor.py index 41d2bf97c8e..2fa703348f9 100644 --- a/tests/components/flic/test_binary_sensor.py +++ b/tests/components/flic/test_binary_sensor.py @@ -29,7 +29,9 @@ class _MockFlicClient: self.channel = channel -async def test_button_uid(hass: HomeAssistant) -> None: +async def test_button_uid( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test UID assignment for Flic buttons.""" address_to_name = { "80:e4:da:78:6e:11": "binary_sensor.flic_80e4da786e11", @@ -53,7 +55,6 @@ async def test_button_uid(hass: HomeAssistant) -> None: await hass.async_block_till_done() - entity_registry = er.async_get(hass) for address, name in address_to_name.items(): state = hass.states.get(name) assert state diff --git a/tests/components/flux_led/test_init.py b/tests/components/flux_led/test_init.py index 969704edd18..7c709bafe73 100644 --- a/tests/components/flux_led/test_init.py +++ b/tests/components/flux_led/test_init.py @@ -139,7 +139,7 @@ async def test_config_entry_retry_right_away_on_discovery(hass: HomeAssistant) - async def test_coordinator_retry_right_away_on_discovery_already_setup( - hass: HomeAssistant, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test discovery makes the coordinator force poll if its already setup.""" config_entry = MockConfigEntry( @@ -156,7 +156,6 @@ async def test_coordinator_retry_right_away_on_discovery_already_setup( assert config_entry.state == ConfigEntryState.LOADED entity_id = "light.bulb_rgbcw_ddeeff" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -241,7 +240,9 @@ async def test_time_sync_startup_and_next_day(hass: HomeAssistant) -> None: assert len(bulb.async_set_time.mock_calls) == 2 -async def test_unique_id_migrate_when_mac_discovered(hass: HomeAssistant) -> None: +async def test_unique_id_migrate_when_mac_discovered( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique id migrated when mac discovered.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -260,7 +261,6 @@ async def test_unique_id_migrate_when_mac_discovered(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert not config_entry.unique_id - entity_registry = er.async_get(hass) assert ( entity_registry.async_get("light.bulb_rgbcw_ddeeff").unique_id == config_entry.entry_id @@ -285,7 +285,7 @@ async def test_unique_id_migrate_when_mac_discovered(hass: HomeAssistant) -> Non async def test_unique_id_migrate_when_mac_discovered_via_discovery( - hass: HomeAssistant, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test unique id migrated when mac discovered via discovery and the mac address from dhcp was one off.""" config_entry = MockConfigEntry( @@ -306,7 +306,6 @@ async def test_unique_id_migrate_when_mac_discovered_via_discovery( await hass.async_block_till_done() assert config_entry.unique_id == MAC_ADDRESS_ONE_OFF - entity_registry = er.async_get(hass) assert ( entity_registry.async_get("light.bulb_rgbcw_ddeeff").unique_id == MAC_ADDRESS_ONE_OFF diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 171112c9097..974a029d143 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -81,7 +81,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_light_unique_id(hass: HomeAssistant) -> None: +async def test_light_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -95,13 +97,14 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.bulb_rgbcw_ddeeff" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS state = hass.states.get(entity_id) assert state.state == STATE_ON -async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None: +async def test_light_goes_unavailable_and_recovers( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light goes unavailable and then recovers.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -115,7 +118,6 @@ async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.bulb_rgbcw_ddeeff" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -135,7 +137,9 @@ async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None: assert state.state == STATE_ON -async def test_light_mac_address_not_found(hass: HomeAssistant) -> None: +async def test_light_mac_address_not_found( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light when we cannot discover the mac address.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE} @@ -147,7 +151,6 @@ async def test_light_mac_address_not_found(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.bulb_rgbcw_ddeeff" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == config_entry.entry_id state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -161,7 +164,12 @@ async def test_light_mac_address_not_found(hass: HomeAssistant) -> None: ], ) async def test_light_device_registry( - hass: HomeAssistant, protocol: str, sw_version: int, model_num: int, model: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + protocol: str, + sw_version: int, + model_num: int, + model: str, ) -> None: """Test a light device registry entry.""" config_entry = MockConfigEntry( @@ -180,7 +188,6 @@ async def test_light_device_registry( await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)} ) diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index ff288c777df..83bd0d1d517 100644 --- a/tests/components/flux_led/test_number.py +++ b/tests/components/flux_led/test_number.py @@ -41,7 +41,9 @@ from . import ( from tests.common import MockConfigEntry -async def test_effects_speed_unique_id(hass: HomeAssistant) -> None: +async def test_effects_speed_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a number unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -55,11 +57,12 @@ async def test_effects_speed_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS -async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None: +async def test_effects_speed_unique_id_no_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a number unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -72,7 +75,6 @@ async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None await hass.async_block_till_done() entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == config_entry.entry_id diff --git a/tests/components/flux_led/test_select.py b/tests/components/flux_led/test_select.py index 91be62e5ab7..c8fd64c6811 100644 --- a/tests/components/flux_led/test_select.py +++ b/tests/components/flux_led/test_select.py @@ -68,7 +68,9 @@ async def test_switch_power_restore_state(hass: HomeAssistant) -> None: ) -async def test_power_restored_unique_id(hass: HomeAssistant) -> None: +async def test_power_restored_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a select unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -82,14 +84,15 @@ async def test_power_restored_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "select.bulb_rgbcw_ddeeff_power_restored" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{MAC_ADDRESS}_power_restored" ) -async def test_power_restored_unique_id_no_discovery(hass: HomeAssistant) -> None: +async def test_power_restored_unique_id_no_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a select unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -102,7 +105,6 @@ async def test_power_restored_unique_id_no_discovery(hass: HomeAssistant) -> Non await hass.async_block_till_done() entity_id = "select.bulb_rgbcw_ddeeff_power_restored" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{config_entry.entry_id}_power_restored" diff --git a/tests/components/flux_led/test_switch.py b/tests/components/flux_led/test_switch.py index cb0034f8d36..5d025a4cab0 100644 --- a/tests/components/flux_led/test_switch.py +++ b/tests/components/flux_led/test_switch.py @@ -71,7 +71,9 @@ async def test_switch_on_off(hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == STATE_ON -async def test_remote_access_unique_id(hass: HomeAssistant) -> None: +async def test_remote_access_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a remote access switch unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -91,13 +93,14 @@ async def test_remote_access_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "switch.bulb_rgbcw_ddeeff_remote_access" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{MAC_ADDRESS}_remote_access" ) -async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None: +async def test_effects_speed_unique_id_no_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a remote access switch unique id when discovery fails.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -116,7 +119,6 @@ async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None await hass.async_block_till_done() entity_id = "switch.bulb_rgbcw_ddeeff_remote_access" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{config_entry.entry_id}_remote_access" diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index 4539619febc..8faec950eb7 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -26,12 +26,12 @@ from tests.common import MockConfigEntry async def test_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, init_integration: MockConfigEntry, ) -> None: """Test the Forecast.Solar sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) state = hass.states.get("sensor.energy_production_today") entry = entity_registry.async_get("sensor.energy_production_today") @@ -173,11 +173,12 @@ async def test_sensors( ), ) async def test_disabled_by_default( - hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + entity_id: str, ) -> None: """Test the Forecast.Solar sensors that are disabled by default.""" - entity_registry = er.async_get(hass) - state = hass.states.get(entity_id) assert state is None @@ -209,6 +210,7 @@ async def test_disabled_by_default( ) async def test_enabling_disable_by_default( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_forecast_solar: MagicMock, key: str, @@ -218,7 +220,6 @@ async def test_enabling_disable_by_default( """Test the Forecast.Solar sensors that are disabled by default.""" entry_id = mock_config_entry.entry_id entity_id = f"{SENSOR_DOMAIN}.{key}" - entity_registry = er.async_get(hass) # Pre-create registry entry for disabled by default sensor entity_registry.async_get_or_create( diff --git a/tests/components/freedompro/test_binary_sensor.py b/tests/components/freedompro/test_binary_sensor.py index 5efa5ca96f7..84e421a8653 100644 --- a/tests/components/freedompro/test_binary_sensor.py +++ b/tests/components/freedompro/test_binary_sensor.py @@ -45,6 +45,8 @@ from tests.common import async_fire_time_changed ) async def test_binary_sensor_get_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, init_integration, entity_id: str, uid: str, @@ -53,10 +55,8 @@ async def test_binary_sensor_get_state( ) -> None: """Test states of the binary_sensor.""" init_integration - registry = er.async_get(hass) - registry_device = dr.async_get(hass) - device = registry_device.async_get_device(identifiers={("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" @@ -67,7 +67,7 @@ async def test_binary_sensor_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -84,7 +84,7 @@ async def test_binary_sensor_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -110,7 +110,7 @@ async def test_binary_sensor_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py index 41a550b3c50..581c6d05448 100644 --- a/tests/components/freedompro/test_climate.py +++ b/tests/components/freedompro/test_climate.py @@ -28,11 +28,13 @@ from tests.common import async_fire_time_changed uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*TWMYQKL3UVED4HSIIB9GXJWJZBQCXG-9VE-N2IUAIWI" -async def test_climate_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_climate_get_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration, +) -> None: """Test states of the climate.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} @@ -84,10 +86,11 @@ async def test_climate_get_state(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20 -async def test_climate_set_off(hass: HomeAssistant, init_integration) -> None: +async def test_climate_set_off( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set off climate.""" init_integration - entity_registry = er.async_get(hass) entity_id = "climate.thermostat" state = hass.states.get(entity_id) @@ -115,11 +118,10 @@ async def test_climate_set_off(hass: HomeAssistant, init_integration) -> None: async def test_climate_set_unsupported_hvac_mode( - hass: HomeAssistant, init_integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration ) -> None: """Test set unsupported hvac mode climate.""" init_integration - entity_registry = er.async_get(hass) entity_id = "climate.thermostat" state = hass.states.get(entity_id) @@ -139,10 +141,11 @@ async def test_climate_set_unsupported_hvac_mode( ) -async def test_climate_set_temperature(hass: HomeAssistant, init_integration) -> None: +async def test_climate_set_temperature( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set temperature climate.""" init_integration - entity_registry = er.async_get(hass) entity_id = "climate.thermostat" state = hass.states.get(entity_id) @@ -185,11 +188,10 @@ async def test_climate_set_temperature(hass: HomeAssistant, init_integration) -> async def test_climate_set_temperature_unsupported_hvac_mode( - hass: HomeAssistant, init_integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration ) -> None: """Test set temperature climate unsupported hvac mode.""" init_integration - entity_registry = er.async_get(hass) entity_id = "climate.thermostat" state = hass.states.get(entity_id) diff --git a/tests/components/freedompro/test_cover.py b/tests/components/freedompro/test_cover.py index af54b1c2793..a4c837194fe 100644 --- a/tests/components/freedompro/test_cover.py +++ b/tests/components/freedompro/test_cover.py @@ -36,6 +36,8 @@ from tests.common import async_fire_time_changed ) async def test_cover_get_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, init_integration, entity_id: str, uid: str, @@ -44,10 +46,8 @@ async def test_cover_get_state( ) -> None: """Test states of the cover.""" init_integration - registry = er.async_get(hass) - registry_device = dr.async_get(hass) - device = registry_device.async_get_device(identifiers={("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" @@ -59,7 +59,7 @@ async def test_cover_get_state( assert state.state == STATE_CLOSED assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -76,7 +76,7 @@ async def test_cover_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -96,6 +96,7 @@ async def test_cover_get_state( ) async def test_cover_set_position( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration, entity_id: str, uid: str, @@ -104,14 +105,13 @@ async def test_cover_set_position( ) -> None: """Test set position of the cover.""" init_integration - registry = er.async_get(hass) state = hass.states.get(entity_id) assert state assert state.state == STATE_CLOSED assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -151,6 +151,7 @@ async def test_cover_set_position( ) async def test_cover_close( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration, entity_id: str, uid: str, @@ -159,7 +160,6 @@ async def test_cover_close( ) -> None: """Test close cover.""" init_integration - registry = er.async_get(hass) states_response = get_states_response_for_uid(uid) states_response[0]["state"]["position"] = 100 @@ -176,7 +176,7 @@ async def test_cover_close( assert state.state == STATE_OPEN assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -214,6 +214,7 @@ async def test_cover_close( ) async def test_cover_open( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration, entity_id: str, uid: str, @@ -222,14 +223,13 @@ async def test_cover_open( ) -> None: """Test open cover.""" init_integration - registry = er.async_get(hass) state = hass.states.get(entity_id) assert state assert state.state == STATE_CLOSED assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_fan.py b/tests/components/freedompro/test_fan.py index b5acf3e496a..80b1e5613eb 100644 --- a/tests/components/freedompro/test_fan.py +++ b/tests/components/freedompro/test_fan.py @@ -21,13 +21,16 @@ from tests.common import async_fire_time_changed uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*ILYH1E3DWZOVMNEUIMDYMNLOW-LFRQFDPWWJOVHVDOS" -async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_fan_get_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration, +) -> None: """Test states of the fan.""" init_integration - registry = er.async_get(hass) - registry_device = dr.async_get(hass) - device = registry_device.async_get_device(identifiers={("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" @@ -41,7 +44,7 @@ async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -59,7 +62,7 @@ async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: assert state assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -67,10 +70,11 @@ async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 50 -async def test_fan_set_off(hass: HomeAssistant, init_integration) -> None: +async def test_fan_set_off( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test turn off the fan.""" init_integration - registry = er.async_get(hass) entity_id = "fan.bedroom" @@ -91,7 +95,7 @@ async def test_fan_set_off(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 50 assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -120,10 +124,11 @@ async def test_fan_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF -async def test_fan_set_on(hass: HomeAssistant, init_integration) -> None: +async def test_fan_set_on( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test turn on the fan.""" init_integration - registry = er.async_get(hass) entity_id = "fan.bedroom" state = hass.states.get(entity_id) @@ -132,7 +137,7 @@ async def test_fan_set_on(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -160,10 +165,11 @@ async def test_fan_set_on(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON -async def test_fan_set_percent(hass: HomeAssistant, init_integration) -> None: +async def test_fan_set_percent( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test turn on the fan.""" init_integration - registry = er.async_get(hass) entity_id = "fan.bedroom" state = hass.states.get(entity_id) @@ -172,7 +178,7 @@ async def test_fan_set_percent(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_light.py b/tests/components/freedompro/test_light.py index 1b06abd1e85..53cb59d5646 100644 --- a/tests/components/freedompro/test_light.py +++ b/tests/components/freedompro/test_light.py @@ -21,10 +21,11 @@ def mock_freedompro_put_state(): yield -async def test_light_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_light_get_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test states of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.lightbulb" state = hass.states.get(entity_id) @@ -32,7 +33,7 @@ async def test_light_get_state(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "lightbulb" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id @@ -40,10 +41,11 @@ async def test_light_get_state(hass: HomeAssistant, init_integration) -> None: ) -async def test_light_set_on(hass: HomeAssistant, init_integration) -> None: +async def test_light_set_on( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set on of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.lightbulb" state = hass.states.get(entity_id) @@ -51,7 +53,7 @@ async def test_light_set_on(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "lightbulb" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id @@ -70,10 +72,11 @@ async def test_light_set_on(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON -async def test_light_set_off(hass: HomeAssistant, init_integration) -> None: +async def test_light_set_off( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set off of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.bedroomlight" state = hass.states.get(entity_id) @@ -81,7 +84,7 @@ async def test_light_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF assert state.attributes.get("friendly_name") == "bedroomlight" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id @@ -100,10 +103,11 @@ async def test_light_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF -async def test_light_set_brightness(hass: HomeAssistant, init_integration) -> None: +async def test_light_set_brightness( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set brightness of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.lightbulb" state = hass.states.get(entity_id) @@ -111,7 +115,7 @@ async def test_light_set_brightness(hass: HomeAssistant, init_integration) -> No assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "lightbulb" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id @@ -131,10 +135,11 @@ async def test_light_set_brightness(hass: HomeAssistant, init_integration) -> No assert int(state.attributes[ATTR_BRIGHTNESS]) == 0 -async def test_light_set_hue(hass: HomeAssistant, init_integration) -> None: +async def test_light_set_hue( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set brightness of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.lightbulb" state = hass.states.get(entity_id) @@ -142,7 +147,7 @@ async def test_light_set_hue(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "lightbulb" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id diff --git a/tests/components/freedompro/test_lock.py b/tests/components/freedompro/test_lock.py index c9f75e6b594..37145d6fe95 100644 --- a/tests/components/freedompro/test_lock.py +++ b/tests/components/freedompro/test_lock.py @@ -20,13 +20,16 @@ from tests.common import async_fire_time_changed uid = "2WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*2VAS3HTWINNZ5N6HVEIPDJ6NX85P2-AM-GSYWUCNPU0" -async def test_lock_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_lock_get_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration, +) -> None: """Test states of the lock.""" init_integration - registry = er.async_get(hass) - registry_device = dr.async_get(hass) - device = registry_device.async_get_device(identifiers={("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" @@ -39,7 +42,7 @@ async def test_lock_get_state(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_UNLOCKED assert state.attributes.get("friendly_name") == "lock" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -56,17 +59,18 @@ async def test_lock_get_state(hass: HomeAssistant, init_integration) -> None: assert state assert state.attributes.get("friendly_name") == "lock" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid assert state.state == STATE_LOCKED -async def test_lock_set_unlock(hass: HomeAssistant, init_integration) -> None: +async def test_lock_set_unlock( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set on of the lock.""" init_integration - registry = er.async_get(hass) entity_id = "lock.lock" @@ -85,7 +89,7 @@ async def test_lock_set_unlock(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_LOCKED assert state.attributes.get("friendly_name") == "lock" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -111,10 +115,11 @@ async def test_lock_set_unlock(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_UNLOCKED -async def test_lock_set_lock(hass: HomeAssistant, init_integration) -> None: +async def test_lock_set_lock( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set on of the lock.""" init_integration - registry = er.async_get(hass) entity_id = "lock.lock" state = hass.states.get(entity_id) @@ -122,7 +127,7 @@ async def test_lock_set_lock(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_UNLOCKED assert state.attributes.get("friendly_name") == "lock" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_sensor.py b/tests/components/freedompro/test_sensor.py index 89acfb3cc32..c06ce5b0794 100644 --- a/tests/components/freedompro/test_sensor.py +++ b/tests/components/freedompro/test_sensor.py @@ -34,17 +34,21 @@ from tests.common import async_fire_time_changed ], ) async def test_sensor_get_state( - hass: HomeAssistant, init_integration, entity_id: str, uid: str, name: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration, + entity_id: str, + uid: str, + name: str, ) -> None: """Test states of the sensor.""" init_integration - registry = er.async_get(hass) state = hass.states.get(entity_id) assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -68,7 +72,7 @@ async def test_sensor_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_switch.py b/tests/components/freedompro/test_switch.py index 03647e4389d..7d72a87a7b5 100644 --- a/tests/components/freedompro/test_switch.py +++ b/tests/components/freedompro/test_switch.py @@ -16,10 +16,11 @@ from tests.common import async_fire_time_changed uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*1JKU1MVWHQL-Z9SCUS85VFXMRGNDCDNDDUVVDKBU31W" -async def test_switch_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_switch_get_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test states of the switch.""" init_integration - registry = er.async_get(hass) entity_id = "switch.irrigation_switch" state = hass.states.get(entity_id) @@ -27,7 +28,7 @@ async def test_switch_get_state(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF assert state.attributes.get("friendly_name") == "Irrigation switch" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -44,17 +45,18 @@ async def test_switch_get_state(hass: HomeAssistant, init_integration) -> None: assert state assert state.attributes.get("friendly_name") == "Irrigation switch" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid assert state.state == STATE_ON -async def test_switch_set_off(hass: HomeAssistant, init_integration) -> None: +async def test_switch_set_off( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set off of the switch.""" init_integration - registry = er.async_get(hass) entity_id = "switch.irrigation_switch" @@ -73,7 +75,7 @@ async def test_switch_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "Irrigation switch" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -101,10 +103,11 @@ async def test_switch_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF -async def test_switch_set_on(hass: HomeAssistant, init_integration) -> None: +async def test_switch_set_on( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set on of the switch.""" init_integration - registry = er.async_get(hass) entity_id = "switch.irrigation_switch" state = hass.states.get(entity_id) @@ -112,7 +115,7 @@ async def test_switch_set_on(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF assert state.attributes.get("friendly_name") == "Irrigation switch" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index b07b8225c3e..5c8d30772f0 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -72,6 +72,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: ) async def test_update_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, fritz: Mock, entitydata: dict, old_unique_id: str, @@ -85,7 +86,6 @@ async def test_update_unique_id( ) entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=entry, @@ -131,6 +131,7 @@ async def test_update_unique_id( ) async def test_update_unique_id_no_change( hass: HomeAssistant, + entity_registry: er.EntityRegistry, fritz: Mock, entitydata: dict, unique_id: str, @@ -143,7 +144,6 @@ async def test_update_unique_id_no_change( ) entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity = entity_registry.async_get_or_create( **entitydata, config_entry=entry, diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index b4c0209e9af..b363d966c01 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -26,7 +26,9 @@ from tests.common import async_fire_time_changed ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock +) -> None: """Test setup of platform.""" device = FritzDeviceSensorMock() assert await setup_config_entry( @@ -61,7 +63,6 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: ], ) - entity_registry = er.async_get(hass) for sensor in sensors: state = hass.states.get(sensor[0]) assert state diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 53cdf5147fc..4ed1a88190a 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -39,7 +39,9 @@ from tests.common import async_fire_time_changed ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock +) -> None: """Test setup of platform.""" device = FritzDeviceSwitchMock() assert await setup_config_entry( @@ -98,7 +100,6 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: ], ) - entity_registry = er.async_get(hass) for sensor in sensors: state = hass.states.get(sensor[0]) assert state diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index d46c60c3cb3..cc56fea24b2 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -60,7 +60,9 @@ async def test_inverter_error( async def test_inverter_night_rescan( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test dynamic adding of an inverter discovered automatically after a Home Assistant reboot during the night.""" mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) @@ -79,7 +81,6 @@ async def test_inverter_night_rescan( await hass.async_block_till_done() # We expect our inverter to be present now - device_registry = dr.async_get(hass) inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "203200")}) assert inverter_1.manufacturer == "Fronius" @@ -93,13 +94,14 @@ async def test_inverter_night_rescan( async def test_inverter_rescan_interruption( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test interruption of re-scan during runtime to process further.""" mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) config_entry = await setup_fronius_integration(hass, is_logger=True) assert config_entry.state is ConfigEntryState.LOADED - device_registry = dr.async_get(hass) # Expect 1 devices during the night, logger assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) diff --git a/tests/components/fully_kiosk/test_binary_sensor.py b/tests/components/fully_kiosk/test_binary_sensor.py index db37139b0ba..cc003199f26 100644 --- a/tests/components/fully_kiosk/test_binary_sensor.py +++ b/tests/components/fully_kiosk/test_binary_sensor.py @@ -22,14 +22,13 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_binary_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk binary sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("binary_sensor.amazon_fire_plugged_in") assert state assert state.state == STATE_ON diff --git a/tests/components/fully_kiosk/test_button.py b/tests/components/fully_kiosk/test_button.py index fee39be302e..f04935aed0e 100644 --- a/tests/components/fully_kiosk/test_button.py +++ b/tests/components/fully_kiosk/test_button.py @@ -12,13 +12,12 @@ from tests.common import MockConfigEntry async def test_buttons( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk buttons.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - entry = entity_registry.async_get("button.amazon_fire_restart_browser") assert entry assert entry.unique_id == "abcdef-123456-restartApp" diff --git a/tests/components/fully_kiosk/test_diagnostics.py b/tests/components/fully_kiosk/test_diagnostics.py index b1b30bda669..e48867739e8 100644 --- a/tests/components/fully_kiosk/test_diagnostics.py +++ b/tests/components/fully_kiosk/test_diagnostics.py @@ -17,13 +17,12 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, hass_client: ClientSessionGenerator, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test Fully Kiosk diagnostics.""" - - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "abcdef-123456")}) diagnostics = await get_diagnostics_for_device( diff --git a/tests/components/fully_kiosk/test_init.py b/tests/components/fully_kiosk/test_init.py index c53e4168733..5c77b8a9d06 100644 --- a/tests/components/fully_kiosk/test_init.py +++ b/tests/components/fully_kiosk/test_init.py @@ -81,11 +81,10 @@ async def _load_config( async def test_multiple_kiosk_with_empty_mac( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test that multiple kiosk devices with empty MAC don't get merged.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - config_entry1 = MockConfigEntry( title="Test device 1", domain=DOMAIN, diff --git a/tests/components/fully_kiosk/test_media_player.py b/tests/components/fully_kiosk/test_media_player.py index 403b9e26511..4cae64e641e 100644 --- a/tests/components/fully_kiosk/test_media_player.py +++ b/tests/components/fully_kiosk/test_media_player.py @@ -15,13 +15,12 @@ from tests.typing import WebSocketGenerator async def test_media_player( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk media player.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("media_player.amazon_fire") assert state diff --git a/tests/components/fully_kiosk/test_number.py b/tests/components/fully_kiosk/test_number.py index 4843e72465c..286ca7fc0cb 100644 --- a/tests/components/fully_kiosk/test_number.py +++ b/tests/components/fully_kiosk/test_number.py @@ -13,13 +13,12 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_numbers( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk numbers.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("number.amazon_fire_screensaver_timer") assert state assert state.state == "900" diff --git a/tests/components/fully_kiosk/test_sensor.py b/tests/components/fully_kiosk/test_sensor.py index 05fd002a205..40912f0f568 100644 --- a/tests/components/fully_kiosk/test_sensor.py +++ b/tests/components/fully_kiosk/test_sensor.py @@ -26,14 +26,13 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_sensors_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("sensor.amazon_fire_battery") assert state assert state.state == "100" diff --git a/tests/components/fully_kiosk/test_services.py b/tests/components/fully_kiosk/test_services.py index 11d5a74f3d7..af6199f34d9 100644 --- a/tests/components/fully_kiosk/test_services.py +++ b/tests/components/fully_kiosk/test_services.py @@ -23,11 +23,11 @@ from tests.common import MockConfigEntry async def test_services( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test the Fully Kiosk Browser services.""" - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "abcdef-123456")} ) @@ -103,13 +103,13 @@ async def test_services( async def test_service_unloaded_entry( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test service not called when config entry unloaded.""" await init_integration.async_unload(hass) - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "abcdef-123456")} ) @@ -156,12 +156,11 @@ async def test_service_bad_device_id( async def test_service_called_with_non_fkb_target_devices( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Services raise exception when no valid devices provided.""" - device_registry = dr.async_get(hass) - other_domain = "NotFullyKiosk" other_config_id = "555" await hass.config_entries.async_add( diff --git a/tests/components/fully_kiosk/test_switch.py b/tests/components/fully_kiosk/test_switch.py index 8da01ff2fe9..4cbdad8d63a 100644 --- a/tests/components/fully_kiosk/test_switch.py +++ b/tests/components/fully_kiosk/test_switch.py @@ -11,12 +11,13 @@ from tests.common import MockConfigEntry async def test_switches( - hass: HomeAssistant, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test Fully Kiosk switches.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - entity = hass.states.get("switch.amazon_fire_screensaver") assert entity assert entity.state == "off" diff --git a/tests/components/gardena_bluetooth/test_init.py b/tests/components/gardena_bluetooth/test_init.py index b09d2177c22..1f294c6169d 100644 --- a/tests/components/gardena_bluetooth/test_init.py +++ b/tests/components/gardena_bluetooth/test_init.py @@ -20,6 +20,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_entry: MockConfigEntry, mock_read_char_raw: dict[str, bytes], snapshot: SnapshotAssertion, @@ -34,7 +35,6 @@ async def test_setup( assert mock_entry.state is ConfigEntryState.LOADED - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DOMAIN, WATER_TIMER_SERVICE_INFO.address)} ) diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index d279fe981d4..dfdce7635df 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -44,7 +44,7 @@ from tests.common import async_fire_time_changed CONFIG = {gdacs.DOMAIN: {CONF_RADIUS: 200}} -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -106,7 +106,6 @@ async def test_setup(hass: HomeAssistant) -> None: + len(hass.states.async_entity_ids("sensor")) == 4 ) - entity_registry = er.async_get(hass) assert len(entity_registry.entities) == 4 state = hass.states.get("geo_location.drought_name_1") diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index bd97a683989..9c0fa7ddaef 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -170,7 +170,9 @@ async def test_humidifier_switch( assert hass.states.get(ENTITY).attributes.get("action") == "humidifying" -async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp_1 +) -> None: """Test setting a unique ID.""" unique_id = "some_unique_id" _setup_sensor(hass, 18) @@ -190,8 +192,6 @@ async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: ) await hass.async_block_till_done() - entity_registry = er.async_get(hass) - entry = entity_registry.async_get(ENTITY) assert entry assert entry.unique_id == unique_id diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 2a406ddbd79..47a3cdc30af 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -173,7 +173,9 @@ async def test_heater_switch( assert hass.states.get(heater_switch).state == STATE_ON -async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp_1 +) -> None: """Test setting a unique ID.""" unique_id = "some_unique_id" _setup_sensor(hass, 18) @@ -193,8 +195,6 @@ async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: ) await hass.async_block_till_done() - entity_registry = er.async_get(hass) - entry = entity_registry.async_get(ENTITY) assert entry assert entry.unique_id == unique_id diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 72f862f585a..d5ababaee41 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -184,7 +184,11 @@ async def test_data_validation(geofency_client, webhook_id) -> None: async def test_gps_enter_and_exit_home( - hass: HomeAssistant, geofency_client, webhook_id + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + geofency_client, + webhook_id, ) -> None: """Test GPS based zone enter and exit.""" url = f"/api/webhook/{webhook_id}" @@ -223,11 +227,8 @@ async def test_gps_enter_and_exit_home( ] assert current_longitude == NOT_HOME_LONGITUDE - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 1 - - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 1 + assert len(device_registry.devices) == 1 + assert len(entity_registry.entities) == 1 async def test_beacon_enter_and_exit_home( diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index bfe94bbf304..561d9aaedeb 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -38,7 +38,7 @@ from tests.common import async_fire_time_changed CONFIG = {geonetnz_quakes.DOMAIN: {CONF_RADIUS: 200}} -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -80,7 +80,6 @@ async def test_setup(hass: HomeAssistant) -> None: + len(hass.states.async_entity_ids("sensor")) == 4 ) - entity_registry = er.async_get(hass) assert len(entity_registry.entities) == 4 state = hass.states.get("geo_location.title_1") diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py index ab73fc1e75f..0d4484c6d0d 100644 --- a/tests/components/gios/test_init.py +++ b/tests/components/gios/test_init.py @@ -100,11 +100,11 @@ async def test_migrate_device_and_config_entry( assert device_entry.id == migrated_device_entry.id -async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: +async def test_remove_air_quality_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test remove air_quality entities from registry.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( AIR_QUALITY_PLATFORM, DOMAIN, "123", @@ -114,5 +114,5 @@ async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("air_quality.home") + entry = entity_registry.async_get("air_quality.home") assert entry is None diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 82027d2bdb9..e14b4548d86 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -30,10 +30,9 @@ from . import init_integration from tests.common import async_fire_time_changed, load_fixture -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of the sensor.""" await init_integration(hass) - registry = er.async_get(hass) state = hass.states.get("sensor.home_benzene") assert state @@ -46,7 +45,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get("sensor.home_benzene") + entry = entity_registry.async_get("sensor.home_benzene") assert entry assert entry.unique_id == "123-c6h6" @@ -61,7 +60,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_carbon_monoxide") + entry = entity_registry.async_get("sensor.home_carbon_monoxide") assert entry assert entry.unique_id == "123-co" @@ -76,7 +75,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_nitrogen_dioxide") + entry = entity_registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-no2" @@ -94,7 +93,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_nitrogen_dioxide_index") + entry = entity_registry.async_get("sensor.home_nitrogen_dioxide_index") assert entry assert entry.unique_id == "123-no2-index" @@ -109,7 +108,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_ozone") + entry = entity_registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-o3" @@ -127,7 +126,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_ozone_index") + entry = entity_registry.async_get("sensor.home_ozone_index") assert entry assert entry.unique_id == "123-o3-index" @@ -142,7 +141,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_pm10") + entry = entity_registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-pm10" @@ -160,7 +159,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_pm10_index") + entry = entity_registry.async_get("sensor.home_pm10_index") assert entry assert entry.unique_id == "123-pm10-index" @@ -175,7 +174,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_pm2_5") + entry = entity_registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" @@ -193,7 +192,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_pm2_5_index") + entry = entity_registry.async_get("sensor.home_pm2_5_index") assert entry assert entry.unique_id == "123-pm25-index" @@ -208,7 +207,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_sulphur_dioxide") + entry = entity_registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-so2" @@ -226,7 +225,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_sulphur_dioxide_index") + entry = entity_registry.async_get("sensor.home_sulphur_dioxide_index") assert entry assert entry.unique_id == "123-so2-index" @@ -245,7 +244,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_air_quality_index") + entry = entity_registry.async_get("sensor.home_air_quality_index") assert entry assert entry.unique_id == "123-aqi" @@ -365,11 +364,11 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: assert state is None -async def test_unique_id_migration(hass: HomeAssistant) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the unique_id migration.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( PLATFORM, DOMAIN, "123-pm2.5", @@ -379,6 +378,6 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("sensor.home_pm2_5") + entry = entity_registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" diff --git a/tests/components/github/test_init.py b/tests/components/github/test_init.py index f4557632d60..612c6579639 100644 --- a/tests/components/github/test_init.py +++ b/tests/components/github/test_init.py @@ -15,6 +15,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_cleanup( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, @@ -23,7 +24,6 @@ async def test_device_registry_cleanup( mock_config_entry.options = {CONF_REPOSITORIES: ["home-assistant/core"]} await setup_github_integration(hass, mock_config_entry, aioclient_mock) - device_registry = dr.async_get(hass) devices = dr.async_entries_for_config_entry( registry=device_registry, config_entry_id=mock_config_entry.entry_id, diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index d7705854720..095c034abe0 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -61,16 +61,18 @@ async def test_sensor_states(hass: HomeAssistant) -> None: ], ) async def test_migrate_unique_id( - hass: HomeAssistant, object_id: str, old_unique_id: str, new_unique_id: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + object_id: str, + old_unique_id: str, + new_unique_id: str, ) -> None: """Test unique id migration.""" old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - - entity: er.RegistryEntry = ent_reg.async_get_or_create( + entity: er.RegistryEntry = entity_registry.async_get_or_create( suggested_object_id=object_id, disabled_by=None, domain=SENSOR_DOMAIN, @@ -83,6 +85,6 @@ async def test_migrate_unique_id( assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity_migrated = ent_reg.async_get(entity.entity_id) + entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == f"{entry.entry_id}-{new_unique_id}" diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py index 287af75c9cd..539da7e91a2 100644 --- a/tests/components/goalzero/test_init.py +++ b/tests/components/goalzero/test_init.py @@ -66,11 +66,12 @@ async def test_update_failed( async def test_device_info( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test device info.""" entry = await async_init_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3a9673441c0..3617456c9e6 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -653,6 +653,7 @@ async def test_future_event_offset_update_behavior( async def test_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_events_list_items, component_setup, config_entry, @@ -661,7 +662,6 @@ async def test_unique_id( mock_events_list_items([]) assert await component_setup() - entity_registry = er.async_get(hass) registry_entries = er.async_entries_for_config_entry( entity_registry, config_entry.entry_id ) @@ -675,14 +675,13 @@ async def test_unique_id( ) async def test_unique_id_migration( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_events_list_items, component_setup, config_entry, old_unique_id, ) -> None: """Test that old unique id format is migrated to the new format that supports multiple accounts.""" - entity_registry = er.async_get(hass) - # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, @@ -730,14 +729,13 @@ async def test_unique_id_migration( ) async def test_invalid_unique_id_cleanup( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_events_list_items, component_setup, config_entry, mock_calendars_yaml, ) -> None: """Test that old unique id format that is not actually unique is removed.""" - entity_registry = er.async_get(hass) - # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index 4882fd10e80..ef2f1475dad 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -121,11 +121,12 @@ async def test_expired_token_refresh_client_error( async def test_device_info( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, ) -> None: """Test device info.""" await setup_integration() - device_registry = dr.async_get(hass) entry = hass.config_entries.async_entries(DOMAIN)[0] device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 22c3830abf8..a9fc5312bba 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -100,7 +100,11 @@ async def test_missing_data(hass: HomeAssistant, gpslogger_client, webhook_id) - async def test_enter_and_exit( - hass: HomeAssistant, gpslogger_client, webhook_id + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + gpslogger_client, + webhook_id, ) -> None: """Test when there is a known zone.""" url = f"/api/webhook/{webhook_id}" @@ -131,11 +135,8 @@ async def test_enter_and_exit( state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert state_name == STATE_NOT_HOME - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 1 - - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 1 + assert len(device_registry.devices) == 1 + assert len(entity_registry.entities) == 1 async def test_enter_with_attrs( diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py index 15198ac7c5b..10c1d58d3d2 100644 --- a/tests/components/group/test_binary_sensor.py +++ b/tests/components/group/test_binary_sensor.py @@ -13,7 +13,9 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test binary_sensor group default state.""" hass.states.async_set("binary_sensor.kitchen", "on") hass.states.async_set("binary_sensor.bedroom", "on") @@ -42,7 +44,6 @@ async def test_default_state(hass: HomeAssistant) -> None: "binary_sensor.bedroom", ] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("binary_sensor.bedroom_group") assert entry assert entry.unique_id == "unique_identifier" @@ -145,7 +146,9 @@ async def test_state_reporting_all(hass: HomeAssistant) -> None: ) -async def test_state_reporting_any(hass: HomeAssistant) -> None: +async def test_state_reporting_any( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the state reporting in 'any' mode. The group state is unavailable if all group members are unavailable. @@ -171,7 +174,6 @@ async def test_state_reporting_any(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - entity_registry = er.async_get(hass) entry = entity_registry.async_get("binary_sensor.binary_sensor_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 1c8275c7f2d..3189e344c62 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -144,18 +144,22 @@ async def test_config_flow( ), ) async def test_config_flow_hides_members( - hass: HomeAssistant, group_type, extra_input, hide_members, hidden_by + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + group_type, + extra_input, + hide_members, + hidden_by, ) -> None: """Test the config flow hides members if requested.""" fake_uuid = "a266a680b608c32770e6c45bfe6b8411" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( group_type, "test", "unique", suggested_object_id="one" ) assert entry.entity_id == f"{group_type}.one" assert entry.hidden_by is None - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( group_type, "test", "unique3", suggested_object_id="three" ) assert entry.entity_id == f"{group_type}.three" @@ -188,8 +192,8 @@ async def test_config_flow_hides_members( assert result["type"] == FlowResultType.CREATE_ENTRY - assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by - assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by def get_suggested(schema, key): @@ -402,6 +406,7 @@ async def test_all_options( ) async def test_options_flow_hides_members( hass: HomeAssistant, + entity_registry: er.EntityRegistry, group_type, extra_input, hide_members, @@ -410,8 +415,7 @@ async def test_options_flow_hides_members( ) -> None: """Test the options flow hides or unhides members if requested.""" fake_uuid = "a266a680b608c32770e6c45bfe6b8411" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( group_type, "test", "unique1", @@ -420,7 +424,7 @@ async def test_options_flow_hides_members( ) assert entry.entity_id == f"{group_type}.one" - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( group_type, "test", "unique3", @@ -462,8 +466,8 @@ async def test_options_flow_hides_members( assert result["type"] == FlowResultType.CREATE_ENTRY - assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by - assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by COVER_ATTRS = [{"supported_features": 0}, {}] diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 4e0ddc19a31..d0eb3788763 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -249,7 +249,9 @@ async def test_state(hass: HomeAssistant, setup_comp) -> None: @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) -async def test_attributes(hass: HomeAssistant, setup_comp) -> None: +async def test_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp +) -> None: """Test handling of state attributes.""" state = hass.states.get(COVER_GROUP) assert state.state == STATE_UNAVAILABLE @@ -407,7 +409,6 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_ASSUMED_STATE not in state.attributes # Test entity registry integration - entity_registry = er.async_get(hass) entry = entity_registry.async_get(COVER_GROUP) assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_event.py b/tests/components/group/test_event.py index 16ea11fe311..f82cc8f314b 100644 --- a/tests/components/group/test_event.py +++ b/tests/components/group/test_event.py @@ -16,7 +16,9 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test event group default state.""" await async_setup_component( hass, @@ -132,7 +134,6 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state is not None assert state.state == STATE_UNAVAILABLE - entity_registry = er.async_get(hass) entry = entity_registry.async_get("event.remote_control") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 2272a29f6ed..2a1baef6798 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -112,7 +112,9 @@ async def setup_comp(hass, config_count): @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) -async def test_state(hass: HomeAssistant, setup_comp) -> None: +async def test_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp +) -> None: """Test handling of state. The group state is on if at least one group member is on. @@ -201,7 +203,6 @@ async def test_state(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Test entity registry integration - entity_registry = er.async_get(hass) entry = entity_registry.async_get(FAN_GROUP) assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index c439506b52a..5c48385c91e 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1641,13 +1641,12 @@ async def test_plant_group(hass: HomeAssistant) -> None: ) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, group_type: str, member_state: str, extra_options: dict[str, Any], ) -> None: """Test removing a config entry.""" - registry = er.async_get(hass) - members1 = [f"{group_type}.one", f"{group_type}.two"] for member in members1: @@ -1672,7 +1671,7 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are present state = hass.states.get(f"{group_type}.bed_room") assert state.attributes["entity_id"] == members1 - assert registry.async_get(f"{group_type}.bed_room") is not None + assert entity_registry.async_get(f"{group_type}.bed_room") is not None # Remove the config entry assert await hass.config_entries.async_remove(group_config_entry.entry_id) @@ -1680,7 +1679,7 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(f"{group_type}.bed_room") is None - assert registry.async_get(f"{group_type}.bed_room") is None + assert entity_registry.async_get(f"{group_type}.bed_room") is None @pytest.mark.parametrize( @@ -1706,6 +1705,7 @@ async def test_setup_and_remove_config_entry( ) async def test_unhide_members_on_remove( hass: HomeAssistant, + entity_registry: er.EntityRegistry, group_type: str, extra_options: dict[str, Any], hide_members: bool, @@ -1713,10 +1713,7 @@ async def test_unhide_members_on_remove( hidden_by: str, ) -> None: """Test removing a config entry.""" - registry = er.async_get(hass) - - registry = er.async_get(hass) - entry1 = registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( group_type, "test", "unique1", @@ -1725,7 +1722,7 @@ async def test_unhide_members_on_remove( ) assert entry1.entity_id == f"{group_type}.one" - entry3 = registry.async_get_or_create( + entry3 = entity_registry.async_get_or_create( group_type, "test", "unique3", @@ -1734,7 +1731,7 @@ async def test_unhide_members_on_remove( ) assert entry3.entity_id == f"{group_type}.three" - entry4 = registry.async_get_or_create( + entry4 = entity_registry.async_get_or_create( group_type, "test", "unique4", @@ -1766,12 +1763,12 @@ async def test_unhide_members_on_remove( # Remove one entity registry entry, to make sure this does not trip up config entry # removal - registry.async_remove(entry4.entity_id) + entity_registry.async_remove(entry4.entity_id) # Remove the config entry assert await hass.config_entries.async_remove(group_config_entry.entry_id) await hass.async_block_till_done() # Check the group members are unhidden - assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by - assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 062cf161bb9..3051ec502a0 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -49,7 +49,9 @@ from homeassistant.setup import async_setup_component from tests.common import async_capture_events, get_fixture_path -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test light group default state.""" hass.states.async_set("light.kitchen", "on") await async_setup_component( @@ -80,7 +82,6 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_EFFECT_LIST) is None assert state.attributes.get(ATTR_EFFECT) is None - entity_registry = er.async_get(hass) entry = entity_registry.async_get("light.bedroom_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index b8a1838bca5..c8102b79ff9 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -31,7 +31,9 @@ from homeassistant.setup import async_setup_component from tests.common import get_fixture_path -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test lock group default state.""" hass.states.async_set("lock.front", "locked") await async_setup_component( @@ -55,7 +57,6 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state.state == STATE_LOCKED assert state.attributes.get(ATTR_ENTITY_ID) == ["lock.front", "lock.back"] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("lock.door_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index e1f269a947d..9f36693d9ef 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -58,7 +58,9 @@ def media_player_media_seek_fixture(): yield seek -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test media group default state.""" hass.states.async_set("media_player.player_1", "on") await async_setup_component( @@ -86,7 +88,6 @@ async def test_default_state(hass: HomeAssistant) -> None: "media_player.player_2", ] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("media_player.media_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 39c9b788d56..71a53042938 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -64,6 +64,7 @@ PRODUCT_VALUE = prod(VALUES) ) async def test_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, sensor_type: str, result: str, attributes: dict[str, Any], @@ -107,8 +108,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L" - entity_reg = er.async_get(hass) - entity = entity_reg.async_get(f"sensor.sensor_group_{sensor_type}") + entity = entity_registry.async_get(f"sensor.sensor_group_{sensor_type}") assert entity.unique_id == "very_unique_id" diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index bc9a05f4754..86f6eb43ed9 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -24,7 +24,9 @@ from homeassistant.setup import async_setup_component from tests.common import get_fixture_path -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test switch group default state.""" hass.states.async_set("switch.tv", "on") await async_setup_component( @@ -49,7 +51,6 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert state.attributes.get(ATTR_ENTITY_ID) == ["switch.tv", "switch.soundbar"] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("switch.multimedia_group") assert entry assert entry.unique_id == "unique_identifier" From 0c421b73097fb6813cb21d16960850f6b77d3072 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sun, 12 Nov 2023 14:13:49 +0200 Subject: [PATCH 402/982] Add entity description mixin to transmission switches (#103843) * Add entity description mixin for transmission switches * minor fix --- .../components/transmission/switch.py | 70 ++++++++++++------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index 3d18fa3796c..fecda94fbf8 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -1,4 +1,6 @@ """Support for setting the Transmission BitTorrent client Turtle Mode.""" +from collections.abc import Callable +from dataclasses import dataclass import logging from typing import Any @@ -14,9 +16,38 @@ from .coordinator import TransmissionDataUpdateCoordinator _LOGGING = logging.getLogger(__name__) -SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( - SwitchEntityDescription(key="on_off", translation_key="on_off"), - SwitchEntityDescription(key="turtle_mode", translation_key="turtle_mode"), + +@dataclass +class TransmissionSwitchEntityDescriptionMixin: + """Mixin for required keys.""" + + is_on_func: Callable[[TransmissionDataUpdateCoordinator], bool | None] + on_func: Callable[[TransmissionDataUpdateCoordinator], None] + off_func: Callable[[TransmissionDataUpdateCoordinator], None] + + +@dataclass +class TransmissionSwitchEntityDescription( + SwitchEntityDescription, TransmissionSwitchEntityDescriptionMixin +): + """Entity description class for Transmission switches.""" + + +SWITCH_TYPES: tuple[TransmissionSwitchEntityDescription, ...] = ( + TransmissionSwitchEntityDescription( + key="on_off", + translation_key="on_off", + is_on_func=lambda coordinator: coordinator.data.active_torrent_count > 0, + on_func=lambda coordinator: coordinator.start_torrents(), + off_func=lambda coordinator: coordinator.stop_torrents(), + ), + TransmissionSwitchEntityDescription( + key="turtle_mode", + translation_key="turtle_mode", + is_on_func=lambda coordinator: coordinator.get_alt_speed_enabled(), + on_func=lambda coordinator: coordinator.set_alt_speed_enabled(True), + off_func=lambda coordinator: coordinator.set_alt_speed_enabled(False), + ), ) @@ -41,12 +72,13 @@ class TransmissionSwitch( ): """Representation of a Transmission switch.""" + entity_description: TransmissionSwitchEntityDescription _attr_has_entity_name = True def __init__( self, coordinator: TransmissionDataUpdateCoordinator, - entity_description: SwitchEntityDescription, + entity_description: TransmissionSwitchEntityDescription, ) -> None: """Initialize the Transmission switch.""" super().__init__(coordinator) @@ -63,34 +95,18 @@ class TransmissionSwitch( @property def is_on(self) -> bool: """Return true if device is on.""" - active = None - if self.entity_description.key == "on_off": - active = self.coordinator.data.active_torrent_count > 0 - elif self.entity_description.key == "turtle_mode": - active = self.coordinator.get_alt_speed_enabled() - - return bool(active) + return bool(self.entity_description.is_on_func(self.coordinator)) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self.entity_description.key == "on_off": - _LOGGING.debug("Starting all torrents") - await self.hass.async_add_executor_job(self.coordinator.start_torrents) - elif self.entity_description.key == "turtle_mode": - _LOGGING.debug("Turning Turtle Mode of Transmission on") - await self.hass.async_add_executor_job( - self.coordinator.set_alt_speed_enabled, True - ) + await self.hass.async_add_executor_job( + self.entity_description.on_func, self.coordinator + ) await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self.entity_description.key == "on_off": - _LOGGING.debug("Stopping all torrents") - await self.hass.async_add_executor_job(self.coordinator.stop_torrents) - if self.entity_description.key == "turtle_mode": - _LOGGING.debug("Turning Turtle Mode of Transmission off") - await self.hass.async_add_executor_job( - self.coordinator.set_alt_speed_enabled, False - ) + await self.hass.async_add_executor_job( + self.entity_description.off_func, self.coordinator + ) await self.coordinator.async_request_refresh() From bb63da764e7924abc74ba2af2ea65e5e9a8fd184 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Nov 2023 14:30:00 +0100 Subject: [PATCH 403/982] Complete tests for HomeWizard kWh Meter SDM230 (#103840) --- .../homewizard/fixtures/SDM230/device.json | 2 +- .../snapshots/test_diagnostics.ambr | 6 +- .../homewizard/snapshots/test_sensor.ambr | 642 ++++++++++++++++++ tests/components/homewizard/test_number.py | 2 +- tests/components/homewizard/test_sensor.py | 57 ++ tests/components/homewizard/test_switch.py | 8 + 6 files changed, 711 insertions(+), 6 deletions(-) diff --git a/tests/components/homewizard/fixtures/SDM230/device.json b/tests/components/homewizard/fixtures/SDM230/device.json index cd8a58341a7..b6b5c18904e 100644 --- a/tests/components/homewizard/fixtures/SDM230/device.json +++ b/tests/components/homewizard/fixtures/SDM230/device.json @@ -1,5 +1,5 @@ { - "product_type": "SDM230-WIFI", + "product_type": "SDM230-wifi", "product_name": "kWh meter", "serial": "3c39e7aabbcc", "firmware_version": "3.06", diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 861fae48720..a5c3e6ed8ba 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -261,13 +261,11 @@ 'api_version': 'v1', 'firmware_version': '3.06', 'product_name': 'kWh meter', - 'product_type': 'SDM230-WIFI', + 'product_type': 'SDM230-wifi', 'serial': '**REDACTED**', }), 'state': None, - 'system': dict({ - 'cloud_enabled': True, - }), + 'system': None, }), 'entry': dict({ 'ip_address': '**REDACTED**', diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index a20c85fd544..4f1db0ac751 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -3561,3 +3561,645 @@ 'state': '84', }) # --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 83ab183524e..0062e32e54e 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -91,7 +91,7 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-WTR"]) +@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230"]) async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: """Does not load button when device has no support for it.""" assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index d1decb76abf..04795a5e191 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -78,6 +78,19 @@ pytestmark = [ "sensor.device_total_water_usage", ], ), + ( + "SDM230", + [ + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + ], + ), ], ) async def test_sensors( @@ -132,6 +145,12 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "SDM230", + [ + "sensor.device_wi_fi_strength", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -209,6 +228,44 @@ async def test_sensors_unreachable( "sensor.device_gas_meter_identifier", ], ), + ( + "SDM230", + [ + "sensor.device_active_average_demand", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_tariff", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_water_usage", + "sensor.device_dsmr_version", + "sensor.device_gas_meter_identifier", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_gas", + "sensor.device_total_water_usage", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + ], + ), ], ) async def test_entities_not_created_for_device( diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 0571664ec16..13a0bfaa863 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -37,6 +37,14 @@ pytestmark = [ "switch.device_cloud_connection", ], ), + ( + "SDM230", + [ + "switch.device", + "switch.device_switch_lock", + "switch.device_cloud_connection", + ], + ), ], ) async def test_entities_not_created_for_device( From adcd4e59cf216dccd308694b54462cdcb78faff4 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Sun, 12 Nov 2023 16:18:12 +0000 Subject: [PATCH 404/982] More useful message on services.yaml parse error (#103847) --- homeassistant/helpers/service.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3c6bf4436eb..32f51a924f7 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -549,9 +549,11 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T "Unable to find services.yaml for the %s integration", integration.domain ) return {} - except (HomeAssistantError, vol.Invalid): + except (HomeAssistantError, vol.Invalid) as ex: _LOGGER.warning( - "Unable to parse services.yaml for the %s integration", integration.domain + "Unable to parse services.yaml for the %s integration: %s", + integration.domain, + ex, ) return {} From 50e11a7a375af8fb3a99a1e311c555d3c2a30419 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Nov 2023 17:27:32 +0100 Subject: [PATCH 405/982] Tweak loader.resolve_dependencies (#103851) --- homeassistant/loader.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 39564846de3..ce868ab85f3 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -776,11 +776,9 @@ class Integration: if self._all_dependencies_resolved is not None: return self._all_dependencies_resolved + self._all_dependencies_resolved = False try: dependencies = await _async_component_dependencies(self.hass, self) - dependencies.discard(self.domain) - self._all_dependencies = dependencies - self._all_dependencies_resolved = True except IntegrationNotFound as err: _LOGGER.error( ( @@ -790,7 +788,6 @@ class Integration: self.domain, err.domain, ) - self._all_dependencies_resolved = False except CircularDependency as err: _LOGGER.error( ( @@ -801,7 +798,10 @@ class Integration: err.from_domain, err.to_domain, ) - self._all_dependencies_resolved = False + else: + dependencies.discard(self.domain) + self._all_dependencies = dependencies + self._all_dependencies_resolved = True return self._all_dependencies_resolved From b2f31d5763665e13b47a1a9e70938eeb8163025f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 12 Nov 2023 18:28:39 +0100 Subject: [PATCH 406/982] Implement update coordinator in Proximity (#103443) --- .../components/proximity/__init__.py | 301 +++--------------- homeassistant/components/proximity/const.py | 25 ++ .../components/proximity/coordinator.py | 268 ++++++++++++++++ 3 files changed, 330 insertions(+), 264 deletions(-) create mode 100644 homeassistant/components/proximity/const.py create mode 100644 homeassistant/components/proximity/coordinator.py diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 23a8fc3bf64..d2db7632b52 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -5,46 +5,26 @@ import logging import voluptuous as vol -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_DEVICES, - CONF_UNIT_OF_MEASUREMENT, - CONF_ZONE, - UnitOfLength, -) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.const import CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType -from homeassistant.util.location import distance -from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_DIR_OF_TRAVEL, + ATTR_NEAREST, + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + DEFAULT_PROXIMITY_ZONE, + DEFAULT_TOLERANCE, + DOMAIN, + UNITS, +) +from .coordinator import ProximityDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTR_DIR_OF_TRAVEL = "dir_of_travel" -ATTR_DIST_FROM = "dist_to_zone" -ATTR_NEAREST = "nearest" - -CONF_IGNORED_ZONES = "ignored_zones" -CONF_TOLERANCE = "tolerance" - -DEFAULT_DIR_OF_TRAVEL = "not set" -DEFAULT_DIST_TO_ZONE = "not set" -DEFAULT_NEAREST = "not set" -DEFAULT_PROXIMITY_ZONE = "home" -DEFAULT_TOLERANCE = 1 -DOMAIN = "proximity" - -UNITS = [ - UnitOfLength.METERS, - UnitOfLength.KILOMETERS, - UnitOfLength.FEET, - UnitOfLength.YARDS, - UnitOfLength.MILES, -] - ZONE_SCHEMA = vol.Schema( { vol.Optional(CONF_ZONE, default=DEFAULT_PROXIMITY_ZONE): cv.string, @@ -62,52 +42,22 @@ CONFIG_SCHEMA = vol.Schema( ) -@callback -def async_setup_proximity_component( - hass: HomeAssistant, name: str, config: ConfigType -) -> bool: - """Set up the individual proximity component.""" - ignored_zones: list[str] = config[CONF_IGNORED_ZONES] - proximity_devices: list[str] = config[CONF_DEVICES] - tolerance: int = config[CONF_TOLERANCE] - proximity_zone = config[CONF_ZONE] - unit_of_measurement: str = config.get( - CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit - ) - zone_friendly_name = name - - proximity = Proximity( - hass, - zone_friendly_name, - DEFAULT_DIST_TO_ZONE, - DEFAULT_DIR_OF_TRAVEL, - DEFAULT_NEAREST, - ignored_zones, - proximity_devices, - tolerance, - proximity_zone, - unit_of_measurement, - ) - proximity.entity_id = f"{DOMAIN}.{zone_friendly_name}" - - proximity.async_write_ha_state() - - async_track_state_change( - hass, proximity_devices, proximity.async_check_proximity_state_change - ) - - return True - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get the zones and offsets from configuration.yaml.""" + hass.data.setdefault(DOMAIN, {}) for zone, proximity_config in config[DOMAIN].items(): - async_setup_proximity_component(hass, zone, proximity_config) + _LOGGER.debug("setup %s with config:%s", zone, proximity_config) + coordinator = ProximityDataUpdateCoordinator(hass, zone, proximity_config) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][zone] = coordinator + proximity = Proximity(hass, zone, coordinator) + proximity.async_write_ha_state() + await proximity.async_added_to_hass() return True -class Proximity(Entity): +class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): """Representation of a Proximity.""" # This entity is legacy and does not have a platform. @@ -117,203 +67,26 @@ class Proximity(Entity): def __init__( self, hass: HomeAssistant, - zone_friendly_name: str, - dist_to: str, - dir_of_travel: str, - nearest: str, - ignored_zones: list[str], - proximity_devices: list[str], - tolerance: int, - proximity_zone: str, - unit_of_measurement: str, + friendly_name: str, + coordinator: ProximityDataUpdateCoordinator, ) -> None: """Initialize the proximity.""" + super().__init__(coordinator) self.hass = hass - self.friendly_name = zone_friendly_name - self.dist_to: str | int = dist_to - self.dir_of_travel = dir_of_travel - self.nearest = nearest - self.ignored_zones = ignored_zones - self.proximity_devices = proximity_devices - self.tolerance = tolerance - self.proximity_zone = proximity_zone - self._unit_of_measurement = unit_of_measurement + self.entity_id = f"{DOMAIN}.{friendly_name}" + + self._attr_name = friendly_name + self._attr_unit_of_measurement = self.coordinator.unit_of_measurement @property - def name(self) -> str: - """Return the name of the entity.""" - return self.friendly_name - - @property - def state(self) -> str | int: + def state(self) -> str | int | float: """Return the state.""" - return self.dist_to - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement + return self.coordinator.data["dist_to_zone"] @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - return {ATTR_DIR_OF_TRAVEL: self.dir_of_travel, ATTR_NEAREST: self.nearest} - - @callback - def async_check_proximity_state_change( - self, entity: str, old_state: State | None, new_state: State | None - ) -> None: - """Perform the proximity checking.""" - if new_state is None: - return - - entity_name = new_state.name - devices_to_calculate = False - devices_in_zone = "" - - zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") - proximity_latitude = ( - zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None - ) - proximity_longitude = ( - zone_state.attributes.get(ATTR_LONGITUDE) if zone_state else None - ) - - # Check for devices in the monitored zone. - for device in self.proximity_devices: - if (device_state := self.hass.states.get(device)) is None: - devices_to_calculate = True - continue - - if device_state.state not in self.ignored_zones: - devices_to_calculate = True - - # Check the location of all devices. - if (device_state.state).lower() == (self.proximity_zone).lower(): - device_friendly = device_state.name - if devices_in_zone != "": - devices_in_zone = f"{devices_in_zone}, " - devices_in_zone = devices_in_zone + device_friendly - - # No-one to track so reset the entity. - if not devices_to_calculate: - self.dist_to = "not set" - self.dir_of_travel = "not set" - self.nearest = "not set" - self.async_write_ha_state() - return - - # At least one device is in the monitored zone so update the entity. - if devices_in_zone != "": - self.dist_to = 0 - self.dir_of_travel = "arrived" - self.nearest = devices_in_zone - self.async_write_ha_state() - return - - # We can't check proximity because latitude and longitude don't exist. - if "latitude" not in new_state.attributes: - return - - # Collect distances to the zone for all devices. - distances_to_zone: dict[str, float] = {} - for device in self.proximity_devices: - # Ignore devices in an ignored zone. - device_state = self.hass.states.get(device) - if not device_state or device_state.state in self.ignored_zones: - continue - - # Ignore devices if proximity cannot be calculated. - if "latitude" not in device_state.attributes: - continue - - # Calculate the distance to the proximity zone. - proximity = distance( - proximity_latitude, - proximity_longitude, - device_state.attributes[ATTR_LATITUDE], - device_state.attributes[ATTR_LONGITUDE], - ) - - # Add the device and distance to a dictionary. - if not proximity: - continue - distances_to_zone[device] = round( - DistanceConverter.convert( - proximity, UnitOfLength.METERS, self.unit_of_measurement - ), - 1, - ) - - # Loop through each of the distances collected and work out the - # closest. - closest_device: str | None = None - dist_to_zone: float | None = None - - for device, zone in distances_to_zone.items(): - if not dist_to_zone or zone < dist_to_zone: - closest_device = device - dist_to_zone = zone - - # If the closest device is one of the other devices. - if closest_device is not None and closest_device != entity: - self.dist_to = round(distances_to_zone[closest_device]) - self.dir_of_travel = "unknown" - device_state = self.hass.states.get(closest_device) - assert device_state - self.nearest = device_state.name - self.async_write_ha_state() - return - - # Stop if we cannot calculate the direction of travel (i.e. we don't - # have a previous state and a current LAT and LONG). - if old_state is None or "latitude" not in old_state.attributes: - self.dist_to = round(distances_to_zone[entity]) - self.dir_of_travel = "unknown" - self.nearest = entity_name - self.async_write_ha_state() - return - - # Reset the variables - distance_travelled: float = 0 - - # Calculate the distance travelled. - old_distance = distance( - proximity_latitude, - proximity_longitude, - old_state.attributes[ATTR_LATITUDE], - old_state.attributes[ATTR_LONGITUDE], - ) - new_distance = distance( - proximity_latitude, - proximity_longitude, - new_state.attributes[ATTR_LATITUDE], - new_state.attributes[ATTR_LONGITUDE], - ) - assert new_distance is not None and old_distance is not None - distance_travelled = round(new_distance - old_distance, 1) - - # Check for tolerance - if distance_travelled < self.tolerance * -1: - direction_of_travel = "towards" - elif distance_travelled > self.tolerance: - direction_of_travel = "away_from" - else: - direction_of_travel = "stationary" - - # Update the proximity entity - self.dist_to = ( - round(dist_to_zone) if dist_to_zone is not None else DEFAULT_DIST_TO_ZONE - ) - self.dir_of_travel = direction_of_travel - self.nearest = entity_name - self.async_write_ha_state() - _LOGGER.debug( - "proximity.%s update entity: distance=%s: direction=%s: device=%s", - self.friendly_name, - self.dist_to, - direction_of_travel, - entity_name, - ) - - _LOGGER.info("%s: proximity calculation complete", entity_name) + return { + ATTR_DIR_OF_TRAVEL: str(self.coordinator.data["dir_of_travel"]), + ATTR_NEAREST: str(self.coordinator.data["nearest"]), + } diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py new file mode 100644 index 00000000000..a5cee0ffce3 --- /dev/null +++ b/homeassistant/components/proximity/const.py @@ -0,0 +1,25 @@ +"""Constants for Proximity integration.""" + +from homeassistant.const import UnitOfLength + +ATTR_DIR_OF_TRAVEL = "dir_of_travel" +ATTR_DIST_TO = "dist_to_zone" +ATTR_NEAREST = "nearest" + +CONF_IGNORED_ZONES = "ignored_zones" +CONF_TOLERANCE = "tolerance" + +DEFAULT_DIR_OF_TRAVEL = "not set" +DEFAULT_DIST_TO_ZONE = "not set" +DEFAULT_NEAREST = "not set" +DEFAULT_PROXIMITY_ZONE = "home" +DEFAULT_TOLERANCE = 1 +DOMAIN = "proximity" + +UNITS = [ + UnitOfLength.METERS, + UnitOfLength.KILOMETERS, + UnitOfLength.FEET, + UnitOfLength.YARDS, + UnitOfLength.MILES, +] diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py new file mode 100644 index 00000000000..1b5770378dd --- /dev/null +++ b/homeassistant/components/proximity/coordinator.py @@ -0,0 +1,268 @@ +"""Data update coordinator for the Proximity integration.""" + +from dataclasses import dataclass +import logging +from typing import TypedDict + +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_DEVICES, + CONF_UNIT_OF_MEASUREMENT, + CONF_ZONE, + UnitOfLength, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.location import distance +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + DEFAULT_DIR_OF_TRAVEL, + DEFAULT_DIST_TO_ZONE, + DEFAULT_NEAREST, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class StateChangedData: + """StateChangedData class.""" + + entity_id: str + old_state: State | None + new_state: State | None + + +class ProximityData(TypedDict): + """ProximityData type class.""" + + dist_to_zone: str | float + dir_of_travel: str | float + nearest: str | float + + +class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): + """Proximity data update coordinator.""" + + def __init__( + self, hass: HomeAssistant, friendly_name: str, config: ConfigType + ) -> None: + """Initialize the Proximity coordinator.""" + self.ignored_zones: list[str] = config[CONF_IGNORED_ZONES] + self.proximity_devices: list[str] = config[CONF_DEVICES] + self.tolerance: int = config[CONF_TOLERANCE] + self.proximity_zone: str = config[CONF_ZONE] + self.unit_of_measurement: str = config.get( + CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit + ) + self.friendly_name = friendly_name + + super().__init__( + hass, + _LOGGER, + name=friendly_name, + update_interval=None, + ) + + self.data = { + "dist_to_zone": DEFAULT_DIST_TO_ZONE, + "dir_of_travel": DEFAULT_DIR_OF_TRAVEL, + "nearest": DEFAULT_NEAREST, + } + + self.state_change_data: StateChangedData | None = None + async_track_state_change( + hass, self.proximity_devices, self.async_check_proximity_state_change + ) + + async def async_check_proximity_state_change( + self, entity: str, old_state: State | None, new_state: State | None + ) -> None: + """Fetch and process state change event.""" + if new_state is None: + _LOGGER.debug("no new_state -> abort") + return + + # We can't check proximity because latitude and longitude don't exist. + if "latitude" not in new_state.attributes: + _LOGGER.debug("no latitude and longitude -> abort") + return + + self.state_change_data = StateChangedData(entity, old_state, new_state) + await self.async_refresh() + + async def _async_update_data(self) -> ProximityData: + """Calculate Proximity data.""" + if self.state_change_data is None or self.state_change_data.new_state is None: + return self.data + + entity_name = self.state_change_data.new_state.name + devices_to_calculate = False + devices_in_zone = "" + + zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") + proximity_latitude = ( + zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None + ) + proximity_longitude = ( + zone_state.attributes.get(ATTR_LONGITUDE) if zone_state else None + ) + + # Check for devices in the monitored zone. + for device in self.proximity_devices: + if (device_state := self.hass.states.get(device)) is None: + devices_to_calculate = True + continue + + if device_state.state not in self.ignored_zones: + devices_to_calculate = True + + # Check the location of all devices. + if (device_state.state).lower() == (self.proximity_zone).lower(): + device_friendly = device_state.name + if devices_in_zone != "": + devices_in_zone = f"{devices_in_zone}, " + devices_in_zone = devices_in_zone + device_friendly + + # No-one to track so reset the entity. + if not devices_to_calculate: + _LOGGER.debug("no devices_to_calculate -> abort") + return { + "dist_to_zone": DEFAULT_DIST_TO_ZONE, + "dir_of_travel": DEFAULT_DIR_OF_TRAVEL, + "nearest": DEFAULT_NEAREST, + } + + # At least one device is in the monitored zone so update the entity. + if devices_in_zone != "": + _LOGGER.debug("at least on device is in zone -> arrived") + return { + "dist_to_zone": 0, + "dir_of_travel": "arrived", + "nearest": devices_in_zone, + } + + # Collect distances to the zone for all devices. + distances_to_zone: dict[str, float] = {} + for device in self.proximity_devices: + # Ignore devices in an ignored zone. + device_state = self.hass.states.get(device) + if not device_state or device_state.state in self.ignored_zones: + continue + + # Ignore devices if proximity cannot be calculated. + if "latitude" not in device_state.attributes: + continue + + # Calculate the distance to the proximity zone. + proximity = distance( + proximity_latitude, + proximity_longitude, + device_state.attributes[ATTR_LATITUDE], + device_state.attributes[ATTR_LONGITUDE], + ) + + # Add the device and distance to a dictionary. + if not proximity: + continue + distances_to_zone[device] = round( + DistanceConverter.convert( + proximity, UnitOfLength.METERS, self.unit_of_measurement + ), + 1, + ) + + # Loop through each of the distances collected and work out the + # closest. + closest_device: str | None = None + dist_to_zone: float | None = None + + for device, zone in distances_to_zone.items(): + if not dist_to_zone or zone < dist_to_zone: + closest_device = device + dist_to_zone = zone + + # If the closest device is one of the other devices. + if ( + closest_device is not None + and closest_device != self.state_change_data.entity_id + ): + _LOGGER.debug("closest device is one of the other devices -> unknown") + device_state = self.hass.states.get(closest_device) + assert device_state + return { + "dist_to_zone": round(distances_to_zone[closest_device]), + "dir_of_travel": "unknown", + "nearest": device_state.name, + } + + # Stop if we cannot calculate the direction of travel (i.e. we don't + # have a previous state and a current LAT and LONG). + if ( + self.state_change_data.old_state is None + or "latitude" not in self.state_change_data.old_state.attributes + ): + _LOGGER.debug("no lat and lon in old_state -> unknown") + return { + "dist_to_zone": round( + distances_to_zone[self.state_change_data.entity_id] + ), + "dir_of_travel": "unknown", + "nearest": entity_name, + } + + # Reset the variables + distance_travelled: float = 0 + + # Calculate the distance travelled. + old_distance = distance( + proximity_latitude, + proximity_longitude, + self.state_change_data.old_state.attributes[ATTR_LATITUDE], + self.state_change_data.old_state.attributes[ATTR_LONGITUDE], + ) + new_distance = distance( + proximity_latitude, + proximity_longitude, + self.state_change_data.new_state.attributes[ATTR_LATITUDE], + self.state_change_data.new_state.attributes[ATTR_LONGITUDE], + ) + assert new_distance is not None and old_distance is not None + distance_travelled = round(new_distance - old_distance, 1) + + # Check for tolerance + if distance_travelled < self.tolerance * -1: + direction_of_travel = "towards" + elif distance_travelled > self.tolerance: + direction_of_travel = "away_from" + else: + direction_of_travel = "stationary" + + # Update the proximity entity + dist_to: float | str + if dist_to_zone is not None: + dist_to = round(dist_to_zone) + else: + dist_to = DEFAULT_DIST_TO_ZONE + + _LOGGER.debug( + "proximity.%s update entity: distance=%s: direction=%s: device=%s", + self.friendly_name, + dist_to, + direction_of_travel, + entity_name, + ) + + _LOGGER.info("%s: proximity calculation complete", entity_name) + + return { + "dist_to_zone": dist_to, + "dir_of_travel": direction_of_travel, + "nearest": entity_name, + } From fd7d2dfe75371abedbc5a472ce651a37b9b5c30f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Nov 2023 09:48:42 -0800 Subject: [PATCH 407/982] Bump gcal_sync to 6.0.1 (#103861) --- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 509100a5174..fc9107bb8d2 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==5.0.0", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==6.0.1", "oauth2client==4.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6d2d8b1ee21..24d20a524f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -863,7 +863,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==5.0.0 +gcal-sync==6.0.1 # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f244425a17..04270c17f0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -685,7 +685,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==5.0.0 +gcal-sync==6.0.1 # homeassistant.components.geocaching geocachingapi==0.2.1 From 01b3e0c49ea2664fb13808732fe8b14c0bc1035e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Nov 2023 18:55:34 +0100 Subject: [PATCH 408/982] Remove useless code from entity helper tests (#103854) --- tests/helpers/test_entity.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 373dfac0cea..4076afcfad0 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -590,7 +590,6 @@ async def test_async_remove_runs_callbacks(hass: HomeAssistant) -> None: platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() - ent.hass = hass ent.entity_id = "test.test" await platform.async_add_entities([ent]) ent.async_on_remove(lambda: result.append(1)) @@ -604,7 +603,6 @@ async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> No platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() - ent.hass = hass ent.entity_id = "test.test" ent.async_on_remove(lambda: result.append(1)) await platform.async_add_entities([ent]) @@ -841,13 +839,6 @@ async def test_setup_source(hass: HomeAssistant) -> None: async def test_removing_entity_unavailable(hass: HomeAssistant) -> None: """Test removing an entity that is still registered creates an unavailable state.""" - er.RegistryEntry( - entity_id="hello.world", - unique_id="test-unique-id", - platform="test-platform", - disabled_by=None, - ) - platform = MockEntityPlatform(hass, domain="hello") ent = entity.Entity() ent.entity_id = "hello.world" From 6303366cf41f2dc16216ced02fd0baa6be74e0e2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Nov 2023 19:06:12 +0100 Subject: [PATCH 409/982] Tweak config._recursive_merge (#103850) --- homeassistant/config.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 1b7e90996dc..61f3dd963af 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -724,15 +724,15 @@ def _identify_config_schema(module: ComponentProtocol) -> str | None: return None -def _recursive_merge(conf: dict[str, Any], package: dict[str, Any]) -> bool | str: +def _recursive_merge(conf: dict[str, Any], package: dict[str, Any]) -> str | None: """Merge package into conf, recursively.""" - error: bool | str = False + duplicate_key: str | None = None for key, pack_conf in package.items(): if isinstance(pack_conf, dict): if not pack_conf: continue conf[key] = conf.get(key, OrderedDict()) - error = _recursive_merge(conf=conf[key], package=pack_conf) + duplicate_key = _recursive_merge(conf=conf[key], package=pack_conf) elif isinstance(pack_conf, list): conf[key] = cv.remove_falsy( @@ -743,7 +743,7 @@ def _recursive_merge(conf: dict[str, Any], package: dict[str, Any]) -> bool | st if conf.get(key) is not None: return key conf[key] = pack_conf - return error + return duplicate_key async def merge_packages_config( @@ -818,10 +818,10 @@ async def merge_packages_config( ) continue - error = _recursive_merge(conf=config[comp_name], package=comp_conf) - if error: + duplicate_key = _recursive_merge(conf=config[comp_name], package=comp_conf) + if duplicate_key: _log_pkg_error( - pack_name, comp_name, config, f"has duplicate key '{error}'" + pack_name, comp_name, config, f"has duplicate key '{duplicate_key}'" ) return config From 1168956f8c6e11c503b505b42ff03b3f5d0f463b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Nov 2023 19:14:52 +0100 Subject: [PATCH 410/982] Small improvement of yaml util tests (#103853) --- tests/util/yaml/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 932bff01fd9..d133e6f1088 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -70,7 +70,7 @@ def test_simple_dict(try_both_loaders) -> None: @pytest.mark.parametrize("hass_config_yaml", ["message:\n {{ states.state }}"]) -def test_unhashable_key(mock_hass_config_yaml: None) -> None: +def test_unhashable_key(try_both_loaders, mock_hass_config_yaml: None) -> None: """Test an unhashable key.""" with pytest.raises(HomeAssistantError): load_yaml_config_file(YAML_CONFIG_FILE) @@ -538,7 +538,7 @@ def test_c_loader_is_available_in_ci() -> None: assert yaml.loader.HAS_C_LOADER is True -async def test_loading_actual_file_with_syntax( +async def test_loading_actual_file_with_syntax_error( hass: HomeAssistant, try_both_loaders ) -> None: """Test loading a real file with syntax errors.""" From 51b599e1f62c224ffa682772a8524bfda496888d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Nov 2023 19:15:01 +0100 Subject: [PATCH 411/982] Deduplicate some code in `helpers.check_config.async_check_ha_config_file` (#103852) Tweak helpers.check_config.async_check_ha_config_file --- homeassistant/helpers/check_config.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index c333bab782b..441381f9994 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -100,16 +100,13 @@ async def async_check_ha_config_file( # noqa: C901 pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_warning(message, domain, pack_config) - def _comp_error(ex: Exception, domain: str, config: ConfigType) -> None: + def _comp_error(ex: Exception, domain: str, component_config: ConfigType) -> None: """Handle errors from components: async_log_exception.""" + message = _format_config_error(ex, domain, component_config)[0] if domain in frontend_dependencies: - result.add_error( - _format_config_error(ex, domain, config)[0], domain, config - ) + result.add_error(message, domain, component_config) else: - result.add_warning( - _format_config_error(ex, domain, config)[0], domain, config - ) + result.add_warning(message, domain, component_config) async def _get_integration( hass: HomeAssistant, domain: str From 96a19d61ab3ef94f0d6cce05eb4e827ce15d82c9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Nov 2023 10:27:02 -0800 Subject: [PATCH 412/982] Fix bug in Fitbit config flow, and switch to prefer display name (#103869) --- homeassistant/components/fitbit/api.py | 2 +- .../components/fitbit/config_flow.py | 2 +- homeassistant/components/fitbit/model.py | 4 +- tests/components/fitbit/conftest.py | 39 +++++++++--- tests/components/fitbit/test_config_flow.py | 63 ++++++++++++++++++- 5 files changed, 96 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index ceb619c4385..49e51a0fd98 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -69,7 +69,7 @@ class FitbitApi(ABC): profile = response["user"] self._profile = FitbitProfile( encoded_id=profile["encodedId"], - full_name=profile["fullName"], + display_name=profile["displayName"], locale=profile.get("locale"), ) return self._profile diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index dd7e79e2c65..7ef6ecbfa28 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -90,7 +90,7 @@ class OAuth2FlowHandler( await self.async_set_unique_id(profile.encoded_id) self._abort_if_unique_id_configured() - return self.async_create_entry(title=profile.full_name, data=data) + return self.async_create_entry(title=profile.display_name, data=data) async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Handle import from YAML.""" diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py index 38b1d0bb786..cd8ece163a4 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -14,8 +14,8 @@ class FitbitProfile: encoded_id: str """The ID representing the Fitbit user.""" - full_name: str - """The first name value specified in the user's account settings.""" + display_name: str + """The name shown when the user's friends look at their Fitbit profile.""" locale: str | None """The locale defined in the user's Fitbit account settings.""" diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 682fb0edd3b..a076be7f63d 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -32,6 +32,15 @@ PROFILE_USER_ID = "fitbit-api-user-id-1" FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_AUTH_IMPL = "conftest-imported-cred" +FULL_NAME = "First Last" +DISPLAY_NAME = "First L." +PROFILE_DATA = { + "fullName": FULL_NAME, + "displayName": DISPLAY_NAME, + "displayNameSetting": "name", + "firstName": "First", + "lastName": "Last", +} PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json" DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json" @@ -214,20 +223,34 @@ def mock_profile_locale() -> str: return "en_US" +@pytest.fixture(name="profile_data") +def mock_profile_data() -> dict[str, Any]: + """Fixture to return other profile data fields.""" + return PROFILE_DATA + + +@pytest.fixture(name="profile_response") +def mock_profile_response( + profile_id: str, profile_locale: str, profile_data: dict[str, Any] +) -> dict[str, Any]: + """Fixture to construct the fake profile API response.""" + return { + "user": { + "encodedId": profile_id, + "locale": profile_locale, + **profile_data, + }, + } + + @pytest.fixture(name="profile", autouse=True) -def mock_profile(requests_mock: Mocker, profile_id: str, profile_locale: str) -> None: +def mock_profile(requests_mock: Mocker, profile_response: dict[str, Any]) -> None: """Fixture to setup fake requests made to Fitbit API during config flow.""" requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=HTTPStatus.OK, - json={ - "user": { - "encodedId": profile_id, - "fullName": "My name", - "locale": profile_locale, - }, - }, + json=profile_response, ) diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index d51379c9adc..78d20b0fb58 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -17,8 +17,10 @@ from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir from .conftest import ( CLIENT_ID, + DISPLAY_NAME, FAKE_AUTH_IMPL, PROFILE_API_URL, + PROFILE_DATA, PROFILE_USER_ID, SERVER_ACCESS_TOKEN, ) @@ -76,7 +78,7 @@ async def test_full_flow( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 config_entry = entries[0] - assert config_entry.title == "My name" + assert config_entry.title == DISPLAY_NAME assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) @@ -286,7 +288,7 @@ async def test_import_fitbit_config( # Verify valid profile can be fetched from the API config_entry = entries[0] - assert config_entry.title == "My name" + assert config_entry.title == DISPLAY_NAME assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) @@ -598,3 +600,60 @@ async def test_reauth_wrong_user_id( assert result.get("reason") == "wrong_account" assert len(mock_setup.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("profile_data", "expected_title"), + [ + (PROFILE_DATA, DISPLAY_NAME), + ({"displayName": DISPLAY_NAME}, DISPLAY_NAME), + ], + ids=("full_profile_data", "display_name_only"), +) +async def test_partial_profile_data( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, + expected_title: str, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + config_entry = entries[0] + assert config_entry.title == expected_title From 80042aa108b10b81d73df65ec911a1a394fe9353 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 12 Nov 2023 18:35:37 +0000 Subject: [PATCH 413/982] Add binary sensors to V2C (#103722) * Add binary_sensor to V2C * sort platforms * change to generator * make it generator * fix --- .coveragerc | 1 + homeassistant/components/v2c/__init__.py | 7 +- homeassistant/components/v2c/binary_sensor.py | 90 +++++++++++++++++++ homeassistant/components/v2c/strings.json | 11 +++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/v2c/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 4aa8ee4949e..7bec02cc47f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1433,6 +1433,7 @@ omit = homeassistant/components/upnp/sensor.py homeassistant/components/vasttrafik/sensor.py homeassistant/components/v2c/__init__.py + homeassistant/components/v2c/binary_sensor.py homeassistant/components/v2c/coordinator.py homeassistant/components/v2c/entity.py homeassistant/components/v2c/number.py diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 97978a9ebc2..3cf615caa3c 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -11,7 +11,12 @@ from homeassistant.helpers.httpx_client import get_async_client from .const import DOMAIN from .coordinator import V2CUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.NUMBER, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py new file mode 100644 index 00000000000..7776a3398c7 --- /dev/null +++ b/homeassistant/components/v2c/binary_sensor.py @@ -0,0 +1,90 @@ +"""Support for V2C binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pytrydan import Trydan + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator +from .entity import V2CBaseEntity + + +@dataclass +class V2CRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Trydan], bool] + + +@dataclass +class V2CBinarySensorEntityDescription( + BinarySensorEntityDescription, V2CRequiredKeysMixin +): + """Describes an EVSE binary sensor entity.""" + + +TRYDAN_SENSORS = ( + V2CBinarySensorEntityDescription( + key="connected", + translation_key="connected", + device_class=BinarySensorDeviceClass.PLUG, + value_fn=lambda evse: evse.connected, + ), + V2CBinarySensorEntityDescription( + key="charging", + translation_key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda evse: evse.charging, + ), + V2CBinarySensorEntityDescription( + key="ready", + translation_key="ready", + value_fn=lambda evse: evse.ready, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C binary sensor platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + V2CBinarySensorBaseEntity(coordinator, description, config_entry.entry_id) + for description in TRYDAN_SENSORS + ) + + +class V2CBinarySensorBaseEntity(V2CBaseEntity, BinarySensorEntity): + """Defines a base V2C binary_sensor entity.""" + + entity_description: V2CBinarySensorEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: V2CBinarySensorEntityDescription, + entry_id: str, + ) -> None: + """Init the V2C base entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the V2C binary_sensor.""" + return self.entity_description.value_fn(self.coordinator.evse) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index 749cfb9979e..a0cf3aae03a 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -13,6 +13,17 @@ } }, "entity": { + "binary_sensor": { + "connected": { + "name": "Connected" + }, + "charging": { + "name": "Charging" + }, + "ready": { + "name": "Ready" + } + }, "number": { "intensity": { "name": "Intensity" From abb1328a6739d48cf3ee2b8b39cda7c2892876a2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Nov 2023 10:44:26 -0800 Subject: [PATCH 414/982] Fix for Google Calendar API returning invalid RRULE:DATE rules (#103870) --- homeassistant/components/google/calendar.py | 9 +++- tests/components/google/test_calendar.py | 48 +++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index bd0fe18912e..3e34a7234a4 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -521,8 +521,13 @@ class GoogleCalendarEntity( def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" rrule: str | None = None - if len(event.recurrence) == 1: - rrule = event.recurrence[0].lstrip(RRULE_PREFIX) + # Home Assistant expects a single RRULE: and all other rule types are unsupported or ignored + if ( + len(event.recurrence) == 1 + and (raw_rule := event.recurrence[0]) + and raw_rule.startswith(RRULE_PREFIX) + ): + rrule = raw_rule.removeprefix(RRULE_PREFIX) return CalendarEvent( uid=event.ical_uuid, recurrence_id=event.id if event.recurring_event_id else None, diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3617456c9e6..a70cd8aee9f 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1299,3 +1299,51 @@ async def test_event_differs_timezone( "description": event["description"], "supported_features": 3, } + + +async def test_invalid_rrule_fix( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_events_list_items, + component_setup, +) -> None: + """Test that an invalid RRULE returned from Google Calendar API is handled correctly end to end.""" + week_from_today = dt_util.now().date() + datetime.timedelta(days=7) + end_event = week_from_today + datetime.timedelta(days=1) + event = { + **TEST_EVENT, + "start": {"date": week_from_today.isoformat()}, + "end": {"date": end_event.isoformat()}, + "recurrence": [ + "RRULE:DATE;TZID=Europe/Warsaw:20230818T020000,20230915T020000,20231013T020000,20231110T010000,20231208T010000", + ], + } + mock_events_list_items([event]) + + assert await component_setup() + + state = hass.states.get(TEST_ENTITY) + assert state.name == TEST_ENTITY_NAME + assert state.state == STATE_OFF + + # Pick a date range that contains two instances of the event + web_client = await hass_client() + response = await web_client.get( + get_events_url(TEST_ENTITY, "2023-08-10T00:00:00Z", "2023-09-20T00:00:00Z") + ) + assert response.status == HTTPStatus.OK + events = await response.json() + + # Both instances are returned, however the RDATE rule is ignored by Home + # Assistant so they are just treateded as flattened events. + assert len(events) == 2 + + event = events[0] + assert event["uid"] == "cydrevtfuybguinhomj@google.com" + assert event["recurrence_id"] == "_c8rinwq863h45qnucyoi43ny8_20230818" + assert event["rrule"] is None + + event = events[1] + assert event["uid"] == "cydrevtfuybguinhomj@google.com" + assert event["recurrence_id"] == "_c8rinwq863h45qnucyoi43ny8_20230915" + assert event["rrule"] is None From 9ab1cb83d801ee0460e73604ba535829fdce8f13 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 12 Nov 2023 19:45:30 +0100 Subject: [PATCH 415/982] Update a-c* tests to use entity & device registry fixtures (#103793) * Update a-c* tests to use entity & device registry fixtures * Revert some changes * Revert formatting * retrigger CI --- tests/components/asuswrt/test_sensor.py | 16 ++- tests/components/blebox/test_binary_sensor.py | 5 +- tests/components/blebox/test_climate.py | 5 +- tests/components/blebox/test_cover.py | 15 +- tests/components/blebox/test_light.py | 15 +- tests/components/blebox/test_sensor.py | 10 +- tests/components/blebox/test_switch.py | 11 +- .../bmw_connected_drive/test_diagnostics.py | 4 +- .../bmw_connected_drive/test_init.py | 5 +- tests/components/brother/test_sensor.py | 82 ++++++----- tests/components/camera/test_init.py | 16 ++- tests/components/cast/test_media_player.py | 135 ++++++++++++------ tests/components/cloud/test_client.py | 12 +- tests/components/cloud/test_google_config.py | 25 ++-- tests/components/config/test_automation.py | 6 +- tests/components/config/test_scene.py | 6 +- tests/components/config/test_script.py | 10 +- tests/components/counter/test_init.py | 18 +-- tests/components/cpuspeed/test_sensor.py | 4 +- 19 files changed, 239 insertions(+), 161 deletions(-) diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 92f40dd8511..f0e21124fe3 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -30,14 +30,15 @@ SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES @pytest.fixture(name="create_device_registry_devices") -def create_device_registry_devices_fixture(hass: HomeAssistant): +def create_device_registry_devices_fixture( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +): """Create device registry devices so the device tracker entities are enabled when added.""" - dev_reg = dr.async_get(hass) config_entry = MockConfigEntry(domain="something_else") config_entry.add_to_hass(hass) for idx, device in enumerate((MOCK_MACS[2], MOCK_MACS[3])): - dev_reg.async_get_or_create( + device_registry.async_get_or_create( name=f"Device {idx}", config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(device))}, @@ -274,7 +275,9 @@ async def test_options_reload(hass: HomeAssistant, connect_legacy) -> None: assert connect_legacy.return_value.connection.async_connect.call_count == 2 -async def test_unique_id_migration(hass: HomeAssistant, connect_legacy) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry, connect_legacy +) -> None: """Test AsusWRT entities unique id format migration.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -283,9 +286,8 @@ async def test_unique_id_migration(hass: HomeAssistant, connect_legacy) -> None: ) config_entry.add_to_hass(hass) - entity_reg = er.async_get(hass) obj_entity_id = slugify(f"{HOST} Upload") - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( sensor.DOMAIN, DOMAIN, f"{DOMAIN} {ROUTER_MAC_ADDR} Upload", @@ -297,6 +299,6 @@ async def test_unique_id_migration(hass: HomeAssistant, connect_legacy) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - migr_entity = entity_reg.async_get(f"{sensor.DOMAIN}.{obj_entity_id}") + migr_entity = entity_registry.async_get(f"{sensor.DOMAIN}.{obj_entity_id}") assert migr_entity is not None assert migr_entity.unique_id == slugify(f"{ROUTER_MAC_ADDR}_sensor_tx_bytes") diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index 25ab8cab8cb..3c05a425b12 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -28,7 +28,9 @@ def airsensor_fixture() -> tuple[AsyncMock, str]: return feature, "binary_sensor.windrainsensor_0_rain" -async def test_init(rainsensor: AsyncMock, hass: HomeAssistant) -> None: +async def test_init( + rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant +) -> None: """Test binary_sensor initialisation.""" _, entity_id = rainsensor entry = await async_setup_entity(hass, entity_id) @@ -40,7 +42,6 @@ async def test_init(rainsensor: AsyncMock, hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE assert state.state == STATE_ON - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My rain sensor" diff --git a/tests/components/blebox/test_climate.py b/tests/components/blebox/test_climate.py index 2e6e5de4573..6ea6d995900 100644 --- a/tests/components/blebox/test_climate.py +++ b/tests/components/blebox/test_climate.py @@ -76,7 +76,9 @@ def thermobox_fixture(): return (feature, "climate.thermobox_thermostat") -async def test_init(saunabox, hass: HomeAssistant) -> None: +async def test_init( + saunabox, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test default state.""" _, entity_id = saunabox @@ -102,7 +104,6 @@ async def test_init(saunabox, hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My sauna" diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index cbf8f5e589b..8691c886faa 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -98,7 +98,9 @@ def gate_fixture(): return (feature, "cover.gatecontroller_position") -async def test_init_gatecontroller(gatecontroller, hass: HomeAssistant) -> None: +async def test_init_gatecontroller( + gatecontroller, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test gateController default state.""" _, entity_id = gatecontroller @@ -118,7 +120,6 @@ async def test_init_gatecontroller(gatecontroller, hass: HomeAssistant) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My gate controller" @@ -128,7 +129,9 @@ async def test_init_gatecontroller(gatecontroller, hass: HomeAssistant) -> None: assert device.sw_version == "1.23" -async def test_init_shutterbox(shutterbox, hass: HomeAssistant) -> None: +async def test_init_shutterbox( + shutterbox, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test gateBox default state.""" _, entity_id = shutterbox @@ -148,7 +151,6 @@ async def test_init_shutterbox(shutterbox, hass: HomeAssistant) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My shutter" @@ -158,7 +160,9 @@ async def test_init_shutterbox(shutterbox, hass: HomeAssistant) -> None: assert device.sw_version == "1.23" -async def test_init_gatebox(gatebox, hass: HomeAssistant) -> None: +async def test_init_gatebox( + gatebox, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cover default state.""" _, entity_id = gatebox @@ -180,7 +184,6 @@ async def test_init_gatebox(gatebox, hass: HomeAssistant) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My gatebox" diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index e2184df9820..47f38ba815b 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -50,7 +50,9 @@ def dimmer_fixture(): return (feature, "light.dimmerbox_brightness") -async def test_dimmer_init(dimmer, hass: HomeAssistant) -> None: +async def test_dimmer_init( + dimmer, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cover default state.""" _, entity_id = dimmer @@ -66,7 +68,6 @@ async def test_dimmer_init(dimmer, hass: HomeAssistant) -> None: assert state.attributes[ATTR_BRIGHTNESS] == 65 assert state.state == STATE_ON - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My dimmer" @@ -223,7 +224,9 @@ def wlightboxs_fixture(): return (feature, "light.wlightboxs_color") -async def test_wlightbox_s_init(wlightbox_s, hass: HomeAssistant) -> None: +async def test_wlightbox_s_init( + wlightbox_s, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cover default state.""" _, entity_id = wlightbox_s @@ -239,7 +242,6 @@ async def test_wlightbox_s_init(wlightbox_s, hass: HomeAssistant) -> None: assert state.attributes[ATTR_BRIGHTNESS] is None assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My wLightBoxS" @@ -326,7 +328,9 @@ def wlightbox_fixture(): return (feature, "light.wlightbox_color") -async def test_wlightbox_init(wlightbox, hass: HomeAssistant) -> None: +async def test_wlightbox_init( + wlightbox, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cover default state.""" _, entity_id = wlightbox @@ -343,7 +347,6 @@ async def test_wlightbox_init(wlightbox, hass: HomeAssistant) -> None: assert state.attributes[ATTR_RGBW_COLOR] is None assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My wLightBox" diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index 1cfe36e70b6..68990a09a32 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -56,7 +56,9 @@ def tempsensor_fixture(): return (feature, "sensor.tempsensor_0_temperature") -async def test_init(tempsensor, hass: HomeAssistant) -> None: +async def test_init( + tempsensor, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test sensor default state.""" _, entity_id = tempsensor @@ -70,7 +72,6 @@ async def test_init(tempsensor, hass: HomeAssistant) -> None: assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My temperature sensor" @@ -110,7 +111,9 @@ async def test_update_failure( assert f"Updating '{feature_mock.full_name}' failed: " in caplog.text -async def test_airsensor_init(airsensor, hass: HomeAssistant) -> None: +async def test_airsensor_init( + airsensor, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test airSensor default state.""" _, entity_id = airsensor @@ -123,7 +126,6 @@ async def test_airsensor_init(airsensor, hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.PM1 assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My air sensor" diff --git a/tests/components/blebox/test_switch.py b/tests/components/blebox/test_switch.py index 5a425e799c3..db98a2705b2 100644 --- a/tests/components/blebox/test_switch.py +++ b/tests/components/blebox/test_switch.py @@ -44,7 +44,9 @@ def switchbox_fixture(): return (feature, "switch.switchbox_0_relay") -async def test_switchbox_init(switchbox, hass: HomeAssistant, config) -> None: +async def test_switchbox_init( + switchbox, hass: HomeAssistant, device_registry: dr.DeviceRegistry, config +) -> None: """Test switch default state.""" feature_mock, entity_id = switchbox @@ -60,7 +62,6 @@ async def test_switchbox_init(switchbox, hass: HomeAssistant, config) -> None: assert state.state == STATE_OFF - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My switch box" @@ -189,7 +190,9 @@ def switchbox_d_fixture(): return (features, ["switch.switchboxd_0_relay", "switch.switchboxd_1_relay"]) -async def test_switchbox_d_init(switchbox_d, hass: HomeAssistant) -> None: +async def test_switchbox_d_init( + switchbox_d, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test switch default state.""" feature_mocks, entity_ids = switchbox_d @@ -206,7 +209,6 @@ async def test_switchbox_d_init(switchbox_d, hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My relays" @@ -223,7 +225,6 @@ async def test_switchbox_d_init(switchbox_d, hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My relays" diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index 0509409ad0a..11c2b055f6d 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -45,6 +45,7 @@ async def test_config_entry_diagnostics( async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, bmw_fixture, snapshot: SnapshotAssertion, ) -> None: @@ -56,7 +57,6 @@ async def test_device_diagnostics( mock_config_entry = await setup_mocked_integration(hass) - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={(DOMAIN, "WBY00000000REXI01")}, ) @@ -73,6 +73,7 @@ async def test_device_diagnostics( async def test_device_diagnostics_vehicle_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, bmw_fixture, snapshot: SnapshotAssertion, ) -> None: @@ -84,7 +85,6 @@ async def test_device_diagnostics_vehicle_not_found( mock_config_entry = await setup_mocked_integration(hass) - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={(DOMAIN, "WBY00000000REXI01")}, ) diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py index aab41bf6339..bc02437f5ba 100644 --- a/tests/components/bmw_connected_drive/test_init.py +++ b/tests/components/bmw_connected_drive/test_init.py @@ -49,12 +49,12 @@ async def test_migrate_unique_ids( entitydata: dict, old_unique_id: str, new_unique_id: str, + entity_registry: er.EntityRegistry, ) -> None: """Test successful migration of entity unique_ids.""" mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=mock_config_entry, @@ -95,13 +95,12 @@ async def test_dont_migrate_unique_ids( entitydata: dict, old_unique_id: str, new_unique_id: str, + entity_registry: er.EntityRegistry, ) -> None: """Test successful migration of entity unique_ids.""" mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - # create existing entry with new_unique_id existing_entity = entity_registry.async_get_or_create( SENSOR_DOMAIN, diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 42bcb9847f1..4bb5732e616 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -32,14 +32,12 @@ ATTR_REMAINING_PAGES = "remaining_pages" ATTR_COUNTER = "counter" -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of the sensors.""" entry = await init_integration(hass, skip_setup=True) - registry = er.async_get(hass) - # Pre-create registry entries for disabled by default sensors - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "0123456789_uptime", @@ -62,7 +60,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "waiting" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.hl_l2340dw_status") + entry = entity_registry.async_get("sensor.hl_l2340dw_status") assert entry assert entry.unique_id == "0123456789_status" @@ -73,7 +71,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "75" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_toner_remaining") + entry = entity_registry.async_get("sensor.hl_l2340dw_black_toner_remaining") assert entry assert entry.unique_id == "0123456789_black_toner_remaining" @@ -84,7 +82,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "10" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_toner_remaining") + entry = entity_registry.async_get("sensor.hl_l2340dw_cyan_toner_remaining") assert entry assert entry.unique_id == "0123456789_cyan_toner_remaining" @@ -95,7 +93,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "8" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_toner_remaining") + entry = entity_registry.async_get("sensor.hl_l2340dw_magenta_toner_remaining") assert entry assert entry.unique_id == "0123456789_magenta_toner_remaining" @@ -106,7 +104,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "2" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_toner_remaining") + entry = entity_registry.async_get("sensor.hl_l2340dw_yellow_toner_remaining") assert entry assert entry.unique_id == "0123456789_yellow_toner_remaining" @@ -117,7 +115,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_drum_remaining_life" @@ -128,7 +126,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "11014" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_drum_remaining_pages" @@ -139,7 +137,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "986" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_drum_page_counter") assert entry assert entry.unique_id == "0123456789_drum_counter" @@ -150,7 +148,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_black_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_black_drum_remaining_life" @@ -161,7 +159,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "16389" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_black_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_black_drum_remaining_pages" @@ -172,7 +170,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_black_drum_page_counter") assert entry assert entry.unique_id == "0123456789_black_drum_counter" @@ -183,7 +181,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_life" @@ -194,7 +192,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "16389" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_pages" @@ -205,7 +203,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_cyan_drum_page_counter") assert entry assert entry.unique_id == "0123456789_cyan_drum_counter" @@ -216,7 +214,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_lifetime") + entry = entity_registry.async_get( + "sensor.hl_l2340dw_magenta_drum_remaining_lifetime" + ) assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_life" @@ -227,7 +227,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "16389" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_pages" @@ -238,7 +238,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_magenta_drum_page_counter") assert entry assert entry.unique_id == "0123456789_magenta_drum_counter" @@ -249,7 +249,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_lifetime") + entry = entity_registry.async_get( + "sensor.hl_l2340dw_yellow_drum_remaining_lifetime" + ) assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_life" @@ -260,7 +262,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "16389" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_pages" @@ -271,7 +273,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_yellow_drum_page_counter") assert entry assert entry.unique_id == "0123456789_yellow_drum_counter" @@ -282,7 +284,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "97" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_fuser_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_fuser_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_fuser_remaining_life" @@ -293,7 +295,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "97" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_belt_unit_remaining_life" @@ -304,7 +306,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "98" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_pf_kit_1_remaining_life" @@ -315,7 +317,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "986" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_page_counter") assert entry assert entry.unique_id == "0123456789_page_counter" @@ -326,7 +328,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "538" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_duplex_unit_page_counter") assert entry assert entry.unique_id == "0123456789_duplex_unit_pages_counter" @@ -337,7 +339,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "709" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_b_w_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_b_w_pages") assert entry assert entry.unique_id == "0123456789_bw_counter" @@ -348,7 +350,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "902" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_color_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_color_pages") assert entry assert entry.unique_id == "0123456789_color_counter" @@ -360,20 +362,21 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "2019-09-24T12:14:56+00:00" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.hl_l2340dw_last_restart") + entry = entity_registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" -async def test_disabled_by_default_sensors(hass: HomeAssistant) -> None: +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the disabled by default Brother sensors.""" await init_integration(hass) - registry = er.async_get(hass) state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state is None - entry = registry.async_get("sensor.hl_l2340dw_last_restart") + entry = entity_registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" assert entry.disabled @@ -434,11 +437,12 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: assert len(mock_update.mock_calls) == 1 -async def test_unique_id_migration(hass: HomeAssistant) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the unique_id migration.""" - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "0123456789_b/w_counter", @@ -448,6 +452,6 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("sensor.hl_l2340dw_b_w_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_b_w_counter") assert entry assert entry.unique_id == "0123456789_bw_counter" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 2a91a375a13..8e49e00e498 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -367,7 +367,10 @@ async def test_websocket_update_preload_prefs( async def test_websocket_update_orientation_prefs( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + mock_camera, ) -> None: """Test updating camera preferences.""" await async_setup_component(hass, "homeassistant", {}) @@ -387,11 +390,10 @@ async def test_websocket_update_orientation_prefs( assert not response["success"] assert response["error"]["code"] == "update_failed" - registry = er.async_get(hass) - assert not registry.async_get("camera.demo_uniquecamera") + assert not entity_registry.async_get("camera.demo_uniquecamera") # Since we don't have a unique id, we need to create a registry entry - registry.async_get_or_create(DOMAIN, "demo", "uniquecamera") - registry.async_update_entity_options( + entity_registry.async_get_or_create(DOMAIN, "demo", "uniquecamera") + entity_registry.async_update_entity_options( "camera.demo_uniquecamera", DOMAIN, {}, @@ -408,7 +410,9 @@ async def test_websocket_update_orientation_prefs( response = await client.receive_json() assert response["success"] - er_camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] + er_camera_prefs = entity_registry.async_get("camera.demo_uniquecamera").options[ + DOMAIN + ] assert er_camera_prefs[PREF_ORIENTATION] == camera.Orientation.ROTATE_180 assert response["result"][PREF_ORIENTATION] == er_camera_prefs[PREF_ORIENTATION] # Check that the preference was saved diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 3d9feb3e43c..2af5e67f845 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -552,6 +552,7 @@ async def test_auto_cast_chromecasts(hass: HomeAssistant) -> None: async def test_discover_dynamic_group( hass: HomeAssistant, + entity_registry: er.EntityRegistry, get_multizone_status_mock, get_chromecast_mock, caplog: pytest.LogCaptureFixture, @@ -562,8 +563,6 @@ async def test_discover_dynamic_group( zconf_1 = get_fake_zconf(host="host_1", port=23456) zconf_2 = get_fake_zconf(host="host_2", port=34567) - reg = er.async_get(hass) - # Fake dynamic group info tmp1 = MagicMock() tmp1.uuid = FakeUUID @@ -606,7 +605,9 @@ async def test_discover_dynamic_group( get_chromecast_mock.assert_called() get_chromecast_mock.reset_mock() assert add_dev1.call_count == 0 - assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + assert ( + entity_registry.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + ) # Discover other dynamic group cast service with patch( @@ -632,7 +633,9 @@ async def test_discover_dynamic_group( get_chromecast_mock.assert_called() get_chromecast_mock.reset_mock() assert add_dev1.call_count == 0 - assert reg.async_get_entity_id("media_player", "cast", cast_2.uuid) is None + assert ( + entity_registry.async_get_entity_id("media_player", "cast", cast_2.uuid) is None + ) # Get update for cast service with patch( @@ -655,7 +658,9 @@ async def test_discover_dynamic_group( assert len(tasks) == 0 get_chromecast_mock.assert_not_called() assert add_dev1.call_count == 0 - assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + assert ( + entity_registry.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + ) # Remove cast service assert "Disconnecting from chromecast" not in caplog.text @@ -765,14 +770,17 @@ async def test_entity_availability(hass: HomeAssistant) -> None: @pytest.mark.parametrize(("port", "entry_type"), ((8009, None), (12345, None))) async def test_device_registry( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, port, entry_type + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + port, + entry_type, ) -> None: """Test device registry integration.""" assert await async_setup_component(hass, "config", {}) entity_id = "media_player.speaker" - reg = er.async_get(hass) - dev_reg = dr.async_get(hass) info = get_fake_chromecast_info(port=port) @@ -790,9 +798,11 @@ async def test_device_registry( assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) - entity_entry = reg.async_get(entity_id) - device_entry = dev_reg.async_get(entity_entry.device_id) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) + entity_entry = entity_registry.async_get(entity_id) + device_entry = device_registry.async_get(entity_entry.device_id) assert entity_entry.device_id == device_entry.id assert device_entry.entry_type == entry_type @@ -815,14 +825,15 @@ async def test_device_registry( await hass.async_block_till_done() chromecast.disconnect.assert_called_once() - assert reg.async_get(entity_id) is None - assert dev_reg.async_get(entity_entry.device_id) is None + assert entity_registry.async_get(entity_id) is None + assert device_registry.async_get(entity_entry.device_id) is None -async def test_entity_cast_status(hass: HomeAssistant) -> None: +async def test_entity_cast_status( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test handling of cast status.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -839,7 +850,9 @@ async def test_entity_cast_status(hass: HomeAssistant) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # No media status, pause, play, stop not supported assert state.attributes.get("supported_features") == ( @@ -1088,10 +1101,11 @@ async def test_entity_browse_media_audio_only( assert expected_child_2 in response["result"]["children"] -async def test_entity_play_media(hass: HomeAssistant, quick_play_mock) -> None: +async def test_entity_play_media( + hass: HomeAssistant, entity_registry: er.EntityRegistry, quick_play_mock +) -> None: """Test playing media.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1107,7 +1121,9 @@ async def test_entity_play_media(hass: HomeAssistant, quick_play_mock) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # Play_media await hass.services.async_call( @@ -1134,10 +1150,11 @@ async def test_entity_play_media(hass: HomeAssistant, quick_play_mock) -> None: ) -async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock) -> None: +async def test_entity_play_media_cast( + hass: HomeAssistant, entity_registry: er.EntityRegistry, quick_play_mock +) -> None: """Test playing media with cast special features.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1153,7 +1170,9 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock) -> N assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # Play_media - cast with app ID await common.async_play_media(hass, "cast", '{"app_id": "abc123"}', entity_id) @@ -1177,11 +1196,13 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock) -> N async def test_entity_play_media_cast_invalid( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, quick_play_mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, + quick_play_mock, ) -> None: """Test playing media.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1197,7 +1218,9 @@ async def test_entity_play_media_cast_invalid( assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # play_media - media_type cast with invalid JSON with pytest.raises(json.decoder.JSONDecodeError): @@ -1345,11 +1368,13 @@ async def test_entity_play_media_playlist( ], ) async def test_entity_media_content_type( - hass: HomeAssistant, cast_type, default_content_type + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + cast_type, + default_content_type, ) -> None: """Test various content types.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1366,7 +1391,9 @@ async def test_entity_media_content_type( assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) media_status = MagicMock(images=None) media_status.media_is_movie = False @@ -1398,10 +1425,11 @@ async def test_entity_media_content_type( assert state.attributes.get("media_content_type") == "movie" -async def test_entity_control(hass: HomeAssistant, quick_play_mock) -> None: +async def test_entity_control( + hass: HomeAssistant, entity_registry: er.EntityRegistry, quick_play_mock +) -> None: """Test various device and media controls.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1427,7 +1455,9 @@ async def test_entity_control(hass: HomeAssistant, quick_play_mock) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "playing" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) assert state.attributes.get("supported_features") == ( MediaPlayerEntityFeature.PAUSE @@ -1527,10 +1557,11 @@ async def test_entity_control(hass: HomeAssistant, quick_play_mock) -> None: ("app_id", "state_no_media"), [(pychromecast.APP_YOUTUBE, "idle"), ("Netflix", "playing")], ) -async def test_entity_media_states(hass: HomeAssistant, app_id, state_no_media) -> None: +async def test_entity_media_states( + hass: HomeAssistant, entity_registry: er.EntityRegistry, app_id, state_no_media +) -> None: """Test various entity media states.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1546,7 +1577,9 @@ async def test_entity_media_states(hass: HomeAssistant, app_id, state_no_media) assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # App id updated, but no media status chromecast.app_id = app_id @@ -1606,10 +1639,11 @@ async def test_entity_media_states(hass: HomeAssistant, app_id, state_no_media) assert state.state == "unknown" -async def test_entity_media_states_lovelace_app(hass: HomeAssistant) -> None: +async def test_entity_media_states_lovelace_app( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test various entity media states when the lovelace app is active.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1625,7 +1659,9 @@ async def test_entity_media_states_lovelace_app(hass: HomeAssistant) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) chromecast.app_id = CAST_APP_ID_HOMEASSISTANT_LOVELACE cast_status = MagicMock() @@ -1677,10 +1713,11 @@ async def test_entity_media_states_lovelace_app(hass: HomeAssistant) -> None: assert state.state == "unknown" -async def test_group_media_states(hass: HomeAssistant, mz_mock) -> None: +async def test_group_media_states( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mz_mock +) -> None: """Test media states are read from group if entity has no state.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1698,7 +1735,9 @@ async def test_group_media_states(hass: HomeAssistant, mz_mock) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) group_media_status = MagicMock(images=None) player_media_status = MagicMock(images=None) @@ -1734,13 +1773,14 @@ async def test_group_media_states(hass: HomeAssistant, mz_mock) -> None: assert state.state == "playing" -async def test_group_media_states_early(hass: HomeAssistant, mz_mock) -> None: +async def test_group_media_states_early( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mz_mock +) -> None: """Test media states are read from group if entity has no state. This tests case asserts group state is polled when the player is created. """ entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1756,7 +1796,9 @@ async def test_group_media_states_early(hass: HomeAssistant, mz_mock) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "unavailable" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # Check group state is polled when player is first created connection_status = MagicMock() @@ -1788,11 +1830,10 @@ async def test_group_media_states_early(hass: HomeAssistant, mz_mock) -> None: async def test_group_media_control( - hass: HomeAssistant, mz_mock, quick_play_mock + hass: HomeAssistant, entity_registry: er.EntityRegistry, mz_mock, quick_play_mock ) -> None: """Test media controls are handled by group if entity has no state.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1811,7 +1852,9 @@ async def test_group_media_control( assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) group_media_status = MagicMock(images=None) player_media_status = MagicMock(images=None) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 63ec6ad569d..ff718262b10 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -248,10 +248,12 @@ async def test_webhook_msg( async def test_google_config_expose_entity( - hass: HomeAssistant, mock_cloud_setup, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_setup, + mock_cloud_login, ) -> None: """Test Google config exposing entity method uses latest config.""" - entity_registry = er.async_get(hass) # Enable exposing new entities to Google exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] @@ -274,10 +276,12 @@ async def test_google_config_expose_entity( async def test_google_config_should_2fa( - hass: HomeAssistant, mock_cloud_setup, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_setup, + mock_cloud_login, ) -> None: """Test Google config disabling 2FA method uses latest config.""" - entity_registry = er.async_get(hass) # Register a light entity entity_entry = entity_registry.async_get_or_create( diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index fe60ca971a1..39bf60570f2 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -120,11 +120,10 @@ async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> Non async def test_google_update_expose_trigger_sync( - hass: HomeAssistant, cloud_prefs + hass: HomeAssistant, entity_registry: er.EntityRegistry, cloud_prefs ) -> None: """Test Google config responds to updating exposed entities.""" assert await async_setup_component(hass, "homeassistant", {}) - entity_registry = er.async_get(hass) # Enable exposing new entities to Google expose_new(hass, True) @@ -176,10 +175,12 @@ async def test_google_update_expose_trigger_sync( async def test_google_entity_registry_sync( - hass: HomeAssistant, mock_cloud_login, cloud_prefs + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_login, + cloud_prefs, ) -> None: """Test Google config responds to entity registry.""" - entity_registry = er.async_get(hass) # Enable exposing new entities to Google expose_new(hass, True) @@ -246,19 +247,25 @@ async def test_google_entity_registry_sync( async def test_google_device_registry_sync( - hass: HomeAssistant, mock_cloud_login, cloud_prefs + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_login, + cloud_prefs, ) -> None: """Test Google config responds to device registry.""" config = CloudGoogleConfig( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) - ent_reg = er.async_get(hass) # Enable exposing new entities to Google expose_new(hass, True) - entity_entry = ent_reg.async_get_or_create("light", "hue", "1234", device_id="1234") - entity_entry = ent_reg.async_update_entity(entity_entry.entity_id, area_id="ABCD") + entity_entry = entity_registry.async_get_or_create( + "light", "hue", "1234", device_id="1234" + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id="ABCD" + ) with patch.object(config, "async_sync_entities_all"): await config.async_initialize() @@ -293,7 +300,7 @@ async def test_google_device_registry_sync( assert len(mock_sync.mock_calls) == 0 - ent_reg.async_update_entity(entity_entry.entity_id, area_id=None) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=None) # Device registry updated with relevant changes # but entity has area ID so not impacted diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index abe0ed90e86..ad4c7e90851 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -337,13 +337,13 @@ async def test_bad_formatted_automations( async def test_delete_automation( hass: HomeAssistant, hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, hass_config_store, setup_automation, ) -> None: """Test deleting an automation.""" - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 with patch.object(config, "SECTIONS", ["automation"]): assert await async_setup_component(hass, "config", {}) @@ -371,7 +371,7 @@ async def test_delete_automation( assert hass_config_store["automations.yaml"] == [{"id": "moon"}] - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 @pytest.mark.parametrize("automation_config", ({},)) diff --git a/tests/components/config/test_scene.py b/tests/components/config/test_scene.py index 1f09d5e9989..9fd596f7f91 100644 --- a/tests/components/config/test_scene.py +++ b/tests/components/config/test_scene.py @@ -184,13 +184,13 @@ async def test_bad_formatted_scene( async def test_delete_scene( hass: HomeAssistant, hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, hass_config_store, setup_scene, ) -> None: """Test deleting a scene.""" - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 with patch.object(config, "SECTIONS", ["scene"]): assert await async_setup_component(hass, "config", {}) @@ -220,7 +220,7 @@ async def test_delete_scene( {"id": "light_off"}, ] - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 @pytest.mark.parametrize("scene_config", ({},)) diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index cc0352301b4..7cf8cf5833e 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -281,7 +281,10 @@ async def test_update_remove_key_script_config( ), ) async def test_delete_script( - hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_config_store + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + hass_config_store, ) -> None: """Test deleting a script.""" with patch.object(config, "SECTIONS", ["script"]): @@ -292,8 +295,7 @@ async def test_delete_script( "script.two", ] - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 client = await hass_client() @@ -313,7 +315,7 @@ async def test_delete_script( assert hass_config_store["scripts.yaml"] == {"one": {}} - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 @pytest.mark.parametrize("script_config", ({},)) diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 12750363469..53bec13d567 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -502,18 +502,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -525,7 +527,7 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update_min_max( @@ -546,7 +548,7 @@ async def test_update_min_max( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) + entity_registry = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None @@ -554,7 +556,7 @@ async def test_update_min_max( assert state.attributes[ATTR_MAXIMUM] == 100 assert state.attributes[ATTR_MINIMUM] == 10 assert state.attributes[ATTR_STEP] == 3 - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -627,11 +629,11 @@ async def test_create( counter_id = "new_counter" input_entity_id = f"{DOMAIN}.{counter_id}" - ent_reg = er.async_get(hass) + entity_registry = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, counter_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, counter_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/cpuspeed/test_sensor.py b/tests/components/cpuspeed/test_sensor.py index 625f80a6814..457d9c37d14 100644 --- a/tests/components/cpuspeed/test_sensor.py +++ b/tests/components/cpuspeed/test_sensor.py @@ -25,13 +25,13 @@ from tests.common import MockConfigEntry async def test_sensor( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_cpuinfo: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test the CPU Speed sensor.""" await async_setup_component(hass, "homeassistant", {}) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) entry = entity_registry.async_get("sensor.cpu_speed") assert entry From 3a531f5698cf72e81370ef3947959282b0a2d7bc Mon Sep 17 00:00:00 2001 From: dotvav Date: Sun, 12 Nov 2023 19:50:18 +0100 Subject: [PATCH 416/982] Add Hitachi Heat Pumps outdoor temperature sensors (#103806) Add OVP and HLRRWIFI outdoor temperature sensors --- homeassistant/components/overkiz/sensor.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index f56643e8cd4..41c2f4d1a92 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -413,6 +413,22 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ options=["open", "tilt", "closed"], translation_key="three_way_handle_direction", ), + # Hitachi air to air heatpump outdoor temperature sensors (HLRRWIFI protocol) + OverkizSensorDescription( + key=OverkizState.HLRRWIFI_OUTDOOR_TEMPERATURE, + name="Outdoor temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + # Hitachi air to air heatpump outdoor temperature sensors (OVP protocol) + OverkizSensorDescription( + key=OverkizState.OVP_OUTDOOR_TEMPERATURE, + name="Outdoor temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), ] SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCRIPTIONS} From eda475fe25d04ca30dfa6117a85f281ebaf8040f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 12 Nov 2023 19:52:32 +0100 Subject: [PATCH 417/982] Update h* tests to use entity & device registry fixtures (#103866) * Update h* tests to use entity & device registry fixtures * Add missed lines --- tests/components/heos/test_media_player.py | 4 +- tests/components/history_stats/test_sensor.py | 10 +++- .../triggers/test_numeric_state.py | 5 +- .../homeassistant/triggers/test_state.py | 7 ++- tests/components/homekit/test_aidmanager.py | 2 +- tests/components/homekit/test_type_covers.py | 20 +++---- tests/components/homekit/test_type_fans.py | 10 ++-- tests/components/homekit/test_type_lights.py | 12 ++-- .../homekit/test_type_media_players.py | 10 ++-- tests/components/homekit/test_type_sensors.py | 10 ++-- .../homekit/test_type_thermostats.py | 20 +++---- .../specific_devices/test_ecobee3.py | 25 ++++---- .../test_fan_that_changes_features.py | 16 +++-- .../specific_devices/test_vocolinc_vp3.py | 6 +- .../test_alarm_control_panel.py | 5 +- .../homekit_controller/test_binary_sensor.py | 5 +- .../homekit_controller/test_button.py | 5 +- .../homekit_controller/test_camera.py | 5 +- .../homekit_controller/test_climate.py | 5 +- .../homekit_controller/test_connection.py | 11 ++-- .../homekit_controller/test_cover.py | 5 +- .../homekit_controller/test_device_trigger.py | 47 ++++++++++----- .../homekit_controller/test_diagnostics.py | 6 +- .../homekit_controller/test_event.py | 16 ++--- .../components/homekit_controller/test_fan.py | 5 +- .../homekit_controller/test_humidifier.py | 5 +- .../homekit_controller/test_init.py | 19 +++--- .../homekit_controller/test_light.py | 10 ++-- .../homekit_controller/test_lock.py | 5 +- .../homekit_controller/test_media_player.py | 5 +- .../homekit_controller/test_number.py | 5 +- .../homekit_controller/test_select.py | 5 +- .../homekit_controller/test_sensor.py | 2 +- .../homekit_controller/test_switch.py | 5 +- .../homematicip_cloud/test_device.py | 34 ++++++----- tests/components/honeywell/test_climate.py | 10 +++- tests/components/honeywell/test_init.py | 3 +- tests/components/huawei_lte/test_switches.py | 15 ++--- tests/components/hue/test_config_flow.py | 8 ++- tests/components/hue/test_light_v1.py | 19 +++--- tests/components/hue/test_light_v2.py | 8 ++- tests/components/hue/test_migration.py | 60 +++++++++++-------- tests/components/hue/test_scene.py | 8 ++- tests/components/hue/test_sensor_v2.py | 19 +++--- tests/components/hydrawise/test_device.py | 12 ++-- tests/components/hyperion/test_camera.py | 8 ++- tests/components/hyperion/test_light.py | 19 +++--- tests/components/hyperion/test_switch.py | 14 +++-- 48 files changed, 330 insertions(+), 240 deletions(-) diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 1784ba83446..70d96a0d5cb 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -249,6 +249,8 @@ async def test_updates_from_players_changed( async def test_updates_from_players_changed_new_ids( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, config_entry, config, controller, @@ -257,8 +259,6 @@ async def test_updates_from_players_changed_new_ids( ) -> None: """Test player updates from changes to available players.""" await setup_platform(hass, config_entry, config) - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) player = controller.players[1] event = asyncio.Event() diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index bb4b5b275d2..c421a1b8c5c 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -1664,7 +1664,9 @@ async def test_history_stats_handles_floored_timestamps( assert last_times == (start_time, start_time + timedelta(hours=2)) -async def test_unique_id(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_unique_id( + recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id property.""" config = { @@ -1682,5 +1684,7 @@ async def test_unique_id(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() - registry = er.async_get(hass) - assert registry.async_get("sensor.test").unique_id == "some_history_stats_unique_id" + assert ( + entity_registry.async_get("sensor.test").unique_id + == "some_history_stats_unique_id" + ) diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index b5bd748a5dc..92c8aac3eba 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -141,11 +141,10 @@ async def test_if_fires_on_entity_change_below( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) async def test_if_fires_on_entity_change_below_uuid( - hass: HomeAssistant, calls, below + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls, below ) -> None: """Test the firing with changed entity specified by registry entry id.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 9870beedafc..a8f001ff5e0 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -89,12 +89,13 @@ async def test_if_fires_on_entity_change(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_on_entity_change_uuid(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_entity_change_uuid( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for firing on entity change.""" context = Context() - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="beer" ) diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 447cdc99a57..64c5cd9cc74 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -622,9 +622,9 @@ async def test_aid_generation_no_unique_ids_handles_collision( async def test_handle_unique_id_change( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test handling unique id changes.""" - entity_registry = er.async_get(hass) light = entity_registry.async_get_or_create("light", "demo", "old_unique") config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index b8841289611..a44db05a37b 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -607,20 +607,18 @@ async def test_windowcovering_open_close_with_position_and_stop( async def test_windowcovering_basic_restore( - hass: HomeAssistant, hk_driver, events + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "cover", "generic", "1234", suggested_object_id="simple", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "cover", "generic", "9012", @@ -646,19 +644,19 @@ async def test_windowcovering_basic_restore( assert acc.char_position_state is not None -async def test_windowcovering_restore(hass: HomeAssistant, hk_driver, events) -> None: - """Test setting up an entity from state in the event registry.""" +async def test_windowcovering_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: + """Test setting up an entity from state in the event entity_registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "cover", "generic", "1234", suggested_object_id="simple", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "cover", "generic", "9012", diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index df54cce1b3f..118e67a43b1 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -553,19 +553,19 @@ async def test_fan_set_all_one_shot(hass: HomeAssistant, hk_driver, events) -> N assert len(call_set_direction) == 2 -async def test_fan_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_fan_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "fan", "generic", "1234", suggested_object_id="simple", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "fan", "generic", "9012", diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 6fae8337aae..7568e7a4844 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -576,14 +576,16 @@ async def test_light_rgb_color( assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)" -async def test_light_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_light_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create("light", "hue", "1234", suggested_object_id="simple") - registry.async_get_or_create( + entity_registry.async_get_or_create( + "light", "hue", "1234", suggested_object_id="simple" + ) + entity_registry.async_get_or_create( "light", "hue", "9012", diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 104b9dd61ce..1954d6bf8ca 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -428,20 +428,20 @@ async def test_media_player_television_supports_source_select_no_sources( assert acc.support_select_source is False -async def test_tv_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_tv_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "media_player", "generic", "1234", suggested_object_id="simple", original_device_class=MediaPlayerDeviceClass.TV, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "media_player", "generic", "9012", diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index d2f0d87c507..23e53eef94d 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -541,20 +541,20 @@ async def test_binary_device_classes(hass: HomeAssistant, hk_driver) -> None: assert acc.char_detected.display_name == char -async def test_sensor_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_sensor_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "generic", "1234", suggested_object_id="temperature", original_device_class="temperature", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "generic", "12345", diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 1c3fb0914f3..5bfbe0b1627 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -964,16 +964,16 @@ async def test_thermostat_temperature_step_whole( assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1 -async def test_thermostat_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_thermostat_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "climate", "generic", "1234", suggested_object_id="simple" ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "climate", "generic", "9012", @@ -1794,16 +1794,16 @@ async def test_water_heater_get_temperature_range( assert acc.get_temperature_range(state) == (15.5, 21.0) -async def test_water_heater_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_water_heater_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "water_heater", "generic", "1234", suggested_object_id="simple" ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "water_heater", "generic", "9012", diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 7b721e76bba..62051bbf244 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -142,7 +142,9 @@ async def test_ecobee3_setup(hass: HomeAssistant) -> None: async def test_ecobee3_setup_from_cache( - hass: HomeAssistant, hass_storage: dict[str, Any] + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_storage: dict[str, Any], ) -> None: """Test that Ecbobee can be correctly setup from its cached entity map.""" accessories = await setup_accessories_from_file(hass, "ecobee3.json") @@ -163,8 +165,6 @@ async def test_ecobee3_setup_from_cache( await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - climate = entity_registry.async_get("climate.homew") assert climate.unique_id == "00:00:00:00:00:00_1_16" @@ -178,12 +178,12 @@ async def test_ecobee3_setup_from_cache( assert occ3.unique_id == "00:00:00:00:00:00_4_56" -async def test_ecobee3_setup_connection_failure(hass: HomeAssistant) -> None: +async def test_ecobee3_setup_connection_failure( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that Ecbobee can be correctly setup from its cached entity map.""" accessories = await setup_accessories_from_file(hass, "ecobee3.json") - entity_registry = er.async_get(hass) - # Test that the connection fails during initial setup. # No entities should be created. with mock.patch.object(FakePairing, "async_populate_accessories_state") as laac: @@ -218,9 +218,10 @@ async def test_ecobee3_setup_connection_failure(hass: HomeAssistant) -> None: assert occ3.unique_id == "00:00:00:00:00:00_4_56" -async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None: +async def test_ecobee3_add_sensors_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that new sensors are automatically added.""" - entity_registry = er.async_get(hass) # Set up a base Ecobee 3 with no additional sensors. # There shouldn't be any entities but climate visible. @@ -254,9 +255,10 @@ async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None: assert occ3.unique_id == "00:00:00:00:00:00_4_56" -async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None: +async def test_ecobee3_remove_sensors_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that sensors are automatically removed.""" - entity_registry = er.async_get(hass) # Set up a base Ecobee 3 with additional sensors. accessories = await setup_accessories_from_file(hass, "ecobee3.json") @@ -307,10 +309,9 @@ async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None: async def test_ecobee3_services_and_chars_removed( - hass: HomeAssistant, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test handling removal of some services and chars.""" - entity_registry = er.async_get(hass) # Set up a base Ecobee 3 with additional sensors. accessories = await setup_accessories_from_file(hass, "ecobee3.json") diff --git a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py index bae0c0e4ff1..1dc8e9ace68 100644 --- a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py +++ b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py @@ -13,9 +13,10 @@ from ..common import ( ) -async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: +async def test_fan_add_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that new features can be added at runtime.""" - entity_registry = er.async_get(hass) # Set up a basic fan that does not support oscillation accessories = await setup_accessories_from_file( @@ -55,9 +56,10 @@ async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED -async def test_fan_remove_feature_at_runtime(hass: HomeAssistant) -> None: +async def test_fan_remove_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that features can be removed at runtime.""" - entity_registry = er.async_get(hass) # Set up a basic fan that does not support oscillation accessories = await setup_accessories_from_file( @@ -97,9 +99,11 @@ async def test_fan_remove_feature_at_runtime(hass: HomeAssistant) -> None: assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED -async def test_bridge_with_two_fans_one_removed(hass: HomeAssistant) -> None: +async def test_bridge_with_two_fans_one_removed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test a bridge with two fans and one gets removed.""" - entity_registry = er.async_get(hass) # Set up a basic fan that does not support oscillation accessories = await setup_accessories_from_file( diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py index 9c51707b809..6d3c242c382 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -14,10 +14,12 @@ from ..common import ( ) -async def test_vocolinc_vp3_setup(hass: HomeAssistant) -> None: +async def test_vocolinc_vp3_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test that a VOCOlinc VP3 can be correctly setup in HA.""" - entity_registry = er.async_get(hass) outlet = entity_registry.async_get_or_create( "switch", "homekit_controller", diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index 2ca74f8fe75..c38c3d47bfe 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -124,9 +124,10 @@ async def test_switch_read_alarm_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "triggered" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a alarm_control_panel unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() alarm_control_panel_entry = entity_registry.async_get_or_create( "alarm_control_panel", diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index 0a1fd9fc52d..382d6182733 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -173,9 +173,10 @@ async def test_leak_sensor_read_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["device_class"] == BinarySensorDeviceClass.MOISTURE -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a binary_sensor unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() binary_sensor_entry = entity_registry.async_get_or_create( "binary_sensor", diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py index fd21498cf27..1f08b578a93 100644 --- a/tests/components/homekit_controller/test_button.py +++ b/tests/components/homekit_controller/test_button.py @@ -94,9 +94,10 @@ async def test_ecobee_clear_hold_press_button(hass: HomeAssistant) -> None: ) -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a button unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() button_entry = entity_registry.async_get_or_create( "button", diff --git a/tests/components/homekit_controller/test_camera.py b/tests/components/homekit_controller/test_camera.py index 27bc470a953..bbb8e5a8eaa 100644 --- a/tests/components/homekit_controller/test_camera.py +++ b/tests/components/homekit_controller/test_camera.py @@ -16,9 +16,10 @@ def create_camera(accessory): accessory.add_service(ServicesTypes.CAMERA_RTP_STREAM_MANAGEMENT) -async def test_migrate_unique_ids(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test migrating entity unique ids.""" - entity_registry = er.async_get(hass) aid = get_next_aid() camera = entity_registry.async_get_or_create( "camera", diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 0f6a3633bd4..c80016770fd 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -1112,9 +1112,10 @@ async def test_heater_cooler_turn_off(hass: HomeAssistant, utcnow) -> None: assert state.attributes["hvac_action"] == "off" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a switch unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() climate_entry = entity_registry.async_get_or_create( "climate", diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index e5949978215..08169c006ae 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -90,7 +90,9 @@ DEVICE_MIGRATION_TESTS = [ @pytest.mark.parametrize("variant", DEVICE_MIGRATION_TESTS) async def test_migrate_device_id_no_serial_skip_if_other_owner( - hass: HomeAssistant, variant: DeviceMigrationTest + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + variant: DeviceMigrationTest, ) -> None: """Don't migrate unrelated devices. @@ -99,7 +101,6 @@ async def test_migrate_device_id_no_serial_skip_if_other_owner( """ entry = MockConfigEntry() entry.add_to_hass(hass) - device_registry = dr.async_get(hass) bridge = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -122,11 +123,11 @@ async def test_migrate_device_id_no_serial_skip_if_other_owner( @pytest.mark.parametrize("variant", DEVICE_MIGRATION_TESTS) async def test_migrate_device_id_no_serial( - hass: HomeAssistant, variant: DeviceMigrationTest + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + variant: DeviceMigrationTest, ) -> None: """Test that a Ryse smart bridge with four shades can be migrated correctly in HA.""" - device_registry = dr.async_get(hass) - accessories = await setup_accessories_from_file(hass, variant.fixture) fake_controller = await setup_platform(hass) diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 5a389311daa..49462a035e9 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -398,9 +398,10 @@ async def test_read_door_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["obstruction-detected"] is True -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a cover unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() cover_entry = entity_registry.async_get_or_create( "cover", diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index 41b6a9fc7dc..ed3894c331b 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -83,15 +83,18 @@ def create_doorbell(accessory): battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) -async def test_enumerate_remote(hass: HomeAssistant, utcnow) -> None: +async def test_enumerate_remote( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, +) -> None: """Test that remote is correctly enumerated.""" await setup_test_component(hass, create_remote) - entity_registry = er.async_get(hass) bat_sensor = entity_registry.async_get("sensor.testdevice_battery") identify_button = entity_registry.async_get("button.testdevice_identify") - device_registry = dr.async_get(hass) device = device_registry.async_get(bat_sensor.device_id) expected = [ @@ -132,15 +135,18 @@ async def test_enumerate_remote(hass: HomeAssistant, utcnow) -> None: assert triggers == unordered(expected) -async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: +async def test_enumerate_button( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, +) -> None: """Test that a button is correctly enumerated.""" await setup_test_component(hass, create_button) - entity_registry = er.async_get(hass) bat_sensor = entity_registry.async_get("sensor.testdevice_battery") identify_button = entity_registry.async_get("button.testdevice_identify") - device_registry = dr.async_get(hass) device = device_registry.async_get(bat_sensor.device_id) expected = [ @@ -180,15 +186,18 @@ async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: assert triggers == unordered(expected) -async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: +async def test_enumerate_doorbell( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, +) -> None: """Test that a button is correctly enumerated.""" await setup_test_component(hass, create_doorbell) - entity_registry = er.async_get(hass) bat_sensor = entity_registry.async_get("sensor.testdevice_battery") identify_button = entity_registry.async_get("button.testdevice_identify") - device_registry = dr.async_get(hass) device = device_registry.async_get(bat_sensor.device_id) expected = [ @@ -228,14 +237,18 @@ async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: assert triggers == unordered(expected) -async def test_handle_events(hass: HomeAssistant, utcnow, calls) -> None: +async def test_handle_events( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, + calls, +) -> None: """Test that events are handled.""" helper = await setup_test_component(hass, create_remote) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.testdevice_battery") - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert await async_setup_component( @@ -345,14 +358,18 @@ async def test_handle_events(hass: HomeAssistant, utcnow, calls) -> None: assert len(calls) == 2 -async def test_handle_events_late_setup(hass: HomeAssistant, utcnow, calls) -> None: +async def test_handle_events_late_setup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, + calls, +) -> None: """Test that events are handled when setup happens after startup.""" helper = await setup_test_component(hass, create_remote) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.testdevice_battery") - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) await hass.config_entries.async_unload(helper.config_entry.entry_id) diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index 4b5372d980d..0f1073b877d 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -290,14 +290,16 @@ async def test_config_entry( async def test_device( - hass: HomeAssistant, hass_client: ClientSessionGenerator, utcnow + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + utcnow, ) -> None: """Test generating diagnostics for a device entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") config_entry, _ = await setup_test_accessories(hass, accessories) connection = hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] - device_registry = dr.async_get(hass) device = device_registry.async_get(connection.devices[1]) diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) diff --git a/tests/components/homekit_controller/test_event.py b/tests/components/homekit_controller/test_event.py index 9731f429eaf..7fb0d1fd55f 100644 --- a/tests/components/homekit_controller/test_event.py +++ b/tests/components/homekit_controller/test_event.py @@ -64,7 +64,9 @@ def create_doorbell(accessory): battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) -async def test_remote(hass: HomeAssistant, utcnow) -> None: +async def test_remote( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test that remote is supported.""" helper = await setup_test_component(hass, create_remote) @@ -75,8 +77,6 @@ async def test_remote(hass: HomeAssistant, utcnow) -> None: ("event.testdevice_button_4", "Button 4"), ] - entity_registry = er.async_get(hass) - for entity_id, service in entities: button = entity_registry.async_get(entity_id) @@ -109,12 +109,13 @@ async def test_remote(hass: HomeAssistant, utcnow) -> None: assert state.attributes["event_type"] == "long_press" -async def test_button(hass: HomeAssistant, utcnow) -> None: +async def test_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test that a button is correctly enumerated.""" helper = await setup_test_component(hass, create_button) entity_id = "event.testdevice_button_1" - entity_registry = er.async_get(hass) button = entity_registry.async_get(entity_id) assert button.original_device_class == EventDeviceClass.BUTTON @@ -146,12 +147,13 @@ async def test_button(hass: HomeAssistant, utcnow) -> None: assert state.attributes["event_type"] == "long_press" -async def test_doorbell(hass: HomeAssistant, utcnow) -> None: +async def test_doorbell( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test that doorbell service is handled.""" helper = await setup_test_component(hass, create_doorbell) entity_id = "event.testdevice_doorbell" - entity_registry = er.async_get(hass) doorbell = entity_registry.async_get(entity_id) assert doorbell.original_device_class == EventDeviceClass.DOORBELL diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 9256128b2cb..2fb64fc345d 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -811,9 +811,10 @@ async def test_v2_set_percentage_non_standard_rotation_range( ) -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a fan unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() fan_entry = entity_registry.async_get_or_create( "fan", diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py index e412fed0878..718c6957356 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -455,11 +455,12 @@ async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) - assert state.attributes["current_humidity"] == 51 -async def test_migrate_entity_ids(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_entity_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test that we can migrate humidifier entity ids.""" aid = get_next_aid() - entity_registry = er.async_get(hass) humidifier_entry = entity_registry.async_get_or_create( "humidifier", "homekit_controller", diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 23c6e245ac7..7f7bec3bb2f 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -17,7 +17,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -85,7 +84,10 @@ def create_alive_service(accessory): async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) @@ -93,9 +95,7 @@ async def test_device_remove_devices( config_entry = helper.config_entry entry_id = config_entry.entry_id - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities[ALIVE_DEVICE_ENTITY_ID] - device_registry = dr.async_get(hass) + entity = entity_registry.entities[ALIVE_DEVICE_ENTITY_ID] live_device_entry = device_registry.async_get(entity.device_id) assert ( @@ -231,15 +231,16 @@ async def test_ble_device_only_checks_is_available( @pytest.mark.parametrize("example", FIXTURES, ids=lambda val: str(val.stem)) async def test_snapshots( - hass: HomeAssistant, snapshot: SnapshotAssertion, example: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + example: str, ) -> None: """Detect regressions in enumerating a homekit accessory database and building entities.""" accessories = await setup_accessories_from_file(hass, example) config_entry, _ = await setup_test_accessories(hass, accessories) - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - registry_devices = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index d6b36fca22e..5d33d744de7 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -343,9 +343,10 @@ async def test_light_unloaded_removed(hass: HomeAssistant, utcnow) -> None: assert hass.states.get(helper.entity_id).state == STATE_UNAVAILABLE -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a light unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() light_entry = entity_registry.async_get_or_create( "light", @@ -360,9 +361,10 @@ async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: ) -async def test_only_migrate_once(hass: HomeAssistant, utcnow) -> None: +async def test_only_migrate_once( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we handle migration happening after an upgrade and than a downgrade and then an upgrade.""" - entity_registry = er.async_get(hass) aid = get_next_aid() old_light_entry = entity_registry.async_get_or_create( "light", diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index 20a18d1acbe..e265bf586a2 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -117,9 +117,10 @@ async def test_switch_read_lock_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "unlocking" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a lock unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() lock_entry = entity_registry.async_get_or_create( "lock", diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index 140b722d3ab..e9ea1d552ce 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -368,9 +368,10 @@ async def test_tv_set_source_fail(hass: HomeAssistant, utcnow) -> None: assert state.attributes["source"] == "HDMI 1" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a media_player unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() media_player_entry = entity_registry.async_get_or_create( "media_player", diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index a95239c23df..dedff37fa4b 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -29,9 +29,10 @@ def create_switch_with_spray_level(accessory): return service -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a number unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() number = entity_registry.async_get_or_create( "number", diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index 9cfa0bccda3..70228ef3dbb 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -33,9 +33,10 @@ def create_service_with_temperature_units(accessory: Accessory): return service -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test we can migrate a select unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() select = entity_registry.async_get_or_create( "select", diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 829fe8e3cdc..e15227d9d87 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -409,12 +409,12 @@ async def test_rssi_sensor( async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, utcnow, entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: """Test an rssi sensor unique id migration.""" - entity_registry = er.async_get(hass) rssi_sensor = entity_registry.async_get_or_create( "sensor", "homekit_controller", diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index 34003984557..8867ffc9bd1 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -219,9 +219,10 @@ async def test_char_switch_read_state(hass: HomeAssistant, utcnow) -> None: assert switch_1.state == "off" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a switch unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() switch_entry = entity_registry.async_get_or_create( "switch", diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 24842ab8beb..909e94a0d84 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -29,7 +29,10 @@ async def test_hmip_load_all_supported_devices( async def test_hmip_remove_device( - hass: HomeAssistant, default_mock_hap_factory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory, ) -> None: """Test Remove of hmip device.""" entity_id = "light.treppe_ch" @@ -46,9 +49,6 @@ async def test_hmip_remove_device( assert ha_state.state == STATE_ON assert hmip_device - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - pre_device_count = len(device_registry.devices) pre_entity_count = len(entity_registry.entities) pre_mapping_count = len(mock_hap.hmip_device_by_entity_id) @@ -63,7 +63,11 @@ async def test_hmip_remove_device( async def test_hmip_add_device( - hass: HomeAssistant, default_mock_hap_factory, hmip_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory, + hmip_config_entry, ) -> None: """Test Remove of hmip device.""" entity_id = "light.treppe_ch" @@ -80,9 +84,6 @@ async def test_hmip_add_device( assert ha_state.state == STATE_ON assert hmip_device - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - pre_device_count = len(device_registry.devices) pre_entity_count = len(entity_registry.entities) pre_mapping_count = len(mock_hap.hmip_device_by_entity_id) @@ -112,7 +113,12 @@ async def test_hmip_add_device( assert len(new_hap.hmip_device_by_entity_id) == pre_mapping_count -async def test_hmip_remove_group(hass: HomeAssistant, default_mock_hap_factory) -> None: +async def test_hmip_remove_group( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory, +) -> None: """Test Remove of hmip group.""" entity_id = "switch.strom_group" entity_name = "Strom Group" @@ -126,9 +132,6 @@ async def test_hmip_remove_group(hass: HomeAssistant, default_mock_hap_factory) assert ha_state.state == STATE_ON assert hmip_device - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - pre_device_count = len(device_registry.devices) pre_entity_count = len(entity_registry.entities) pre_mapping_count = len(mock_hap.hmip_device_by_entity_id) @@ -254,7 +257,10 @@ async def test_hmip_reset_energy_counter_services( async def test_hmip_multi_area_device( - hass: HomeAssistant, default_mock_hap_factory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory, ) -> None: """Test multi area device. Check if devices are created and referenced.""" entity_id = "binary_sensor.wired_eingangsmodul_32_fach_channel5" @@ -270,12 +276,10 @@ async def test_hmip_multi_area_device( assert ha_state # get the entity - entity_registry = er.async_get(hass) entity = entity_registry.async_get(ha_state.entity_id) assert entity # get the device - device_registry = dr.async_get(hass) device = device_registry.async_get(entity.device_id) assert device.name == "Wired Eingangsmodul – 32-fach" diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 45ce862dba8..9c73e88c3df 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -62,6 +62,7 @@ async def test_no_thermostat_options( async def test_static_attributes( hass: HomeAssistant, + entity_registry: er.EntityRegistry, device: MagicMock, config_entry: MagicMock, snapshot: SnapshotAssertion, @@ -70,7 +71,7 @@ async def test_static_attributes( await init_integration(hass, config_entry) entity_id = f"climate.{device.name}" - entry = er.async_get(hass).async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry state = hass.states.get(entity_id) @@ -1200,7 +1201,10 @@ async def test_async_update_errors( async def test_aux_heat_off_service_call( - hass: HomeAssistant, device: MagicMock, config_entry: MagicMock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device: MagicMock, + config_entry: MagicMock, ) -> None: """Test aux heat off turns of system when no heat configured.""" device.raw_ui_data["SwitchHeatAllowed"] = False @@ -1210,7 +1214,7 @@ async def test_aux_heat_off_service_call( await init_integration(hass, config_entry) entity_id = f"climate.{device.name}" - entry = er.async_get(hass).async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry state = hass.states.get(entity_id) diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 73dda8ed223..695688e77f0 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -124,6 +124,7 @@ async def test_no_devices( async def test_remove_stale_device( hass: HomeAssistant, config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, location: MagicMock, another_device: MagicMock, client: MagicMock, @@ -133,7 +134,6 @@ async def test_remove_stale_device( config_entry.add_to_hass(hass) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("OtherDomain", 7654321)}, @@ -146,7 +146,6 @@ async def test_remove_stale_device( hass.states.async_entity_ids_count() == 6 ) # 2 climate entities; 4 sensor entities - device_registry = dr.async_get(hass) device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) diff --git a/tests/components/huawei_lte/test_switches.py b/tests/components/huawei_lte/test_switches.py index dee4def9596..e686c2356e6 100644 --- a/tests/components/huawei_lte/test_switches.py +++ b/tests/components/huawei_lte/test_switches.py @@ -12,7 +12,6 @@ from homeassistant.components.switch import ( from homeassistant.const import ATTR_ENTITY_ID, CONF_URL, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry @@ -42,13 +41,13 @@ def magic_client(multi_basic_settings_value: dict) -> MagicMock: async def test_huawei_lte_wifi_guest_network_config_entry_when_network_is_not_present( client, hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test switch wifi guest network config entry when network is not present.""" huawei_lte = MockConfigEntry(domain=DOMAIN, data={CONF_URL: "http://huawei-lte"}) huawei_lte.add_to_hass(hass) await hass.config_entries.async_setup(huawei_lte.entry_id) await hass.async_block_till_done() - entity_registry: EntityRegistry = er.async_get(hass) assert not entity_registry.async_is_registered(SWITCH_WIFI_GUEST_NETWORK) @@ -62,13 +61,13 @@ async def test_huawei_lte_wifi_guest_network_config_entry_when_network_is_not_pr async def test_huawei_lte_wifi_guest_network_config_entry_when_network_is_present( client, hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test switch wifi guest network config entry when network is present.""" huawei_lte = MockConfigEntry(domain=DOMAIN, data={CONF_URL: "http://huawei-lte"}) huawei_lte.add_to_hass(hass) await hass.config_entries.async_setup(huawei_lte.entry_id) await hass.async_block_till_done() - entity_registry: EntityRegistry = er.async_get(hass) assert entity_registry.async_is_registered(SWITCH_WIFI_GUEST_NETWORK) @@ -122,7 +121,9 @@ async def test_turn_off_switch_wifi_guest_network(client, hass: HomeAssistant) - return_value=magic_client({"Ssids": {"Ssid": "str"}}), ) async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_str( - client, hass: HomeAssistant + client, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test switch wifi guest network config entry when ssid is a str. @@ -132,7 +133,6 @@ async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_str( huawei_lte.add_to_hass(hass) await hass.config_entries.async_setup(huawei_lte.entry_id) await hass.async_block_till_done() - entity_registry: EntityRegistry = er.async_get(hass) assert not entity_registry.async_is_registered(SWITCH_WIFI_GUEST_NETWORK) @@ -142,7 +142,9 @@ async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_str( return_value=magic_client({"Ssids": {"Ssid": None}}), ) async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_none( - client, hass: HomeAssistant + client, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test switch wifi guest network config entry when ssid is a None. @@ -152,5 +154,4 @@ async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_none( huawei_lte.add_to_hass(hass) await hass.config_entries.async_setup(huawei_lte.entry_id) await hass.async_block_till_done() - entity_registry: EntityRegistry = er.async_get(hass) assert not entity_registry.async_is_registered(SWITCH_WIFI_GUEST_NETWORK) diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 29b94b17da1..51e0a7dde7a 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -527,7 +527,10 @@ def _get_schema_default(schema, key_name): raise KeyError(f"{key_name} not found in schema") -async def test_options_flow_v2(hass: HomeAssistant) -> None: +async def test_options_flow_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: """Test options config flow for a V2 bridge.""" entry = MockConfigEntry( domain="hue", @@ -536,9 +539,8 @@ async def test_options_flow_v2(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - dev_reg = dr.async_get(hass) mock_dev_id = "aabbccddee" - dev_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(const.DOMAIN, mock_dev_id)} ) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index 919f95b6a66..c03e04b633d 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -275,7 +275,9 @@ async def test_lights_color_mode(hass: HomeAssistant, mock_bridge_v1) -> None: ] -async def test_groups(hass: HomeAssistant, mock_bridge_v1) -> None: +async def test_groups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge_v1 +) -> None: """Test the update_lights function with some lights.""" mock_bridge_v1.mock_light_responses.append({}) mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) @@ -295,9 +297,8 @@ async def test_groups(hass: HomeAssistant, mock_bridge_v1) -> None: assert lamp_2 is not None assert lamp_2.state == "on" - ent_reg = er.async_get(hass) - assert ent_reg.async_get("light.group_1").unique_id == "1" - assert ent_reg.async_get("light.group_2").unique_id == "2" + assert entity_registry.async_get("light.group_1").unique_id == "1" + assert entity_registry.async_get("light.group_2").unique_id == "2" async def test_new_group_discovered(hass: HomeAssistant, mock_bridge_v1) -> None: @@ -764,7 +765,12 @@ def test_hs_color() -> None: assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) -async def test_group_features(hass: HomeAssistant, mock_bridge_v1) -> None: +async def test_group_features( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_bridge_v1, +) -> None: """Test group features.""" color_temp_type = "Color temperature light" extended_color_type = "Extended color light" @@ -949,9 +955,6 @@ async def test_group_features(hass: HomeAssistant, mock_bridge_v1) -> None: assert group_3.attributes["supported_color_modes"] == extended_color_mode assert group_3.attributes["supported_features"] == extended_color_feature - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - entry = entity_registry.async_get("light.hue_lamp_1") device_entry = device_registry.async_get(entry.device_id) assert device_entry.suggested_area is None diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index c32abecbd0b..55b0c194781 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -350,7 +350,10 @@ async def test_light_availability( async def test_grouped_lights( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, ) -> None: """Test if all v2 grouped lights get created with correct features.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -359,8 +362,7 @@ async def test_grouped_lights( # test if entities for hue groups are created and enabled by default for entity_id in ("light.test_zone", "light.test_room"): - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry # scene entities should have be assigned to the room/zone device/service diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py index ef51c2a2f89..5ca182d1761 100644 --- a/tests/components/hue/test_migration.py +++ b/tests/components/hue/test_migration.py @@ -44,21 +44,23 @@ async def test_auto_switchover(hass: HomeAssistant) -> None: async def test_light_entity_migration( - hass: HomeAssistant, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_bridge_v2, + mock_config_entry_v2, + v2_resources_test_data, ) -> None: """Test if entity schema for lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 config_entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - # create device/entity with V1 schema in registry - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(hue.DOMAIN, "00:17:88:01:09:aa:bb:65-0b")}, ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "light", hue.DOMAIN, "00:17:88:01:09:aa:bb:65-0b", @@ -77,30 +79,32 @@ async def test_light_entity_migration( await hue.migration.handle_v2_migration(hass, config_entry) # migrated device should now have the new identifier (guid) instead of old style (mac) - migrated_device = dev_reg.async_get(device.id) + migrated_device = device_registry.async_get(device.id) assert migrated_device is not None assert migrated_device.identifiers == { (hue.DOMAIN, "0b216218-d811-4c95-8c55-bbcda50f9d50") } # the entity should have the new unique_id (guid) - migrated_entity = ent_reg.async_get("light.migrated_light_1") + migrated_entity = entity_registry.async_get("light.migrated_light_1") assert migrated_entity is not None assert migrated_entity.unique_id == "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1" async def test_sensor_entity_migration( - hass: HomeAssistant, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_bridge_v2, + mock_config_entry_v2, + v2_resources_test_data, ) -> None: """Test if entity schema for sensors migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 config_entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - # create device with V1 schema in registry for Hue motion sensor device_mac = "00:17:aa:bb:cc:09:ac:c3" - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(hue.DOMAIN, device_mac)} ) @@ -114,7 +118,7 @@ async def test_sensor_entity_migration( # create entities with V1 schema in registry for Hue motion sensor for dev_class, platform, _ in sensor_mappings: - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( platform, hue.DOMAIN, f"{device_mac}-{dev_class}", @@ -134,14 +138,14 @@ async def test_sensor_entity_migration( await hue.migration.handle_v2_migration(hass, config_entry) # migrated device should now have the new identifier (guid) instead of old style (mac) - migrated_device = dev_reg.async_get(device.id) + migrated_device = device_registry.async_get(device.id) assert migrated_device is not None assert migrated_device.identifiers == { (hue.DOMAIN, "2330b45d-6079-4c6e-bba6-1b68afb1a0d6") } # the entities should have the correct V2 unique_id (guid) for dev_class, platform, new_id in sensor_mappings: - migrated_entity = ent_reg.async_get( + migrated_entity = entity_registry.async_get( f"{platform}.hue_migrated_{dev_class}_sensor" ) assert migrated_entity is not None @@ -149,16 +153,18 @@ async def test_sensor_entity_migration( async def test_group_entity_migration_with_v1_id( - hass: HomeAssistant, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + mock_config_entry_v2, + v2_resources_test_data, ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 - ent_reg = er.async_get(hass) - # create (deviceless) entity with V1 schema in registry # using the legacy style group id as unique id - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "light", hue.DOMAIN, "3", @@ -176,22 +182,24 @@ async def test_group_entity_migration_with_v1_id( await hue.migration.handle_v2_migration(hass, config_entry) # the entity should have the new identifier (guid) - migrated_entity = ent_reg.async_get("light.hue_migrated_grouped_light") + migrated_entity = entity_registry.async_get("light.hue_migrated_grouped_light") assert migrated_entity is not None assert migrated_entity.unique_id == "e937f8db-2f0e-49a0-936e-027e60e15b34" async def test_group_entity_migration_with_v2_group_id( - hass: HomeAssistant, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + mock_config_entry_v2, + v2_resources_test_data, ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 - ent_reg = er.async_get(hass) - # create (deviceless) entity with V1 schema in registry # using the V2 group id as unique id - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "light", hue.DOMAIN, "6ddc9066-7e7d-4a03-a773-c73937968296", @@ -209,6 +217,6 @@ async def test_group_entity_migration_with_v2_group_id( await hue.migration.handle_v2_migration(hass, config_entry) # the entity should have the new identifier (guid) - migrated_entity = ent_reg.async_get("light.hue_migrated_grouped_light") + migrated_entity = entity_registry.async_get("light.hue_migrated_grouped_light") assert migrated_entity is not None assert migrated_entity.unique_id == "e937f8db-2f0e-49a0-936e-027e60e15b34" diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index 5fa35cec5b4..ad2d11ff6b6 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -8,7 +8,10 @@ from .const import FAKE_SCENE async def test_scene( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, ) -> None: """Test if (config) scenes get created.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -57,13 +60,12 @@ async def test_scene( assert test_entity.attributes["is_active"] is True # scene entities should have be assigned to the room/zone device/service - ent_reg = er.async_get(hass) for entity_id in ( "scene.test_zone_dynamic_test_scene", "scene.test_room_regular_test_scene", "scene.test_room_smart_test_scene", ): - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.device_id is not None diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 45e39e94119..b8793c99d6c 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -9,7 +9,10 @@ from .const import FAKE_DEVICE, FAKE_SENSOR, FAKE_ZIGBEE_CONNECTIVITY async def test_sensors( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, ) -> None: """Test if all v2 sensors get created with correct features.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -51,8 +54,7 @@ async def test_sensors( # test disabled zigbee_connectivity sensor entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.disabled @@ -60,7 +62,11 @@ async def test_sensors( async def test_enable_sensor( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data, mock_config_entry_v2 + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, + mock_config_entry_v2, ) -> None: """Test enabling of the by default disabled zigbee_connectivity sensor.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -71,15 +77,14 @@ async def test_enable_sensor( await hass.config_entries.async_forward_entry_setup(mock_config_entry_v2, "sensor") entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.disabled assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # enable the entity - updated_entry = ent_reg.async_update_entity( + updated_entry = entity_registry.async_update_entity( entity_entry.entity_id, **{"disabled_by": None} ) assert updated_entry != entity_entry diff --git a/tests/components/hydrawise/test_device.py b/tests/components/hydrawise/test_device.py index 05c402faca7..9d98f2a7b44 100644 --- a/tests/components/hydrawise/test_device.py +++ b/tests/components/hydrawise/test_device.py @@ -9,10 +9,12 @@ from homeassistant.helpers import device_registry as dr def test_zones_in_device_registry( - hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, + mock_pydrawise: Mock, ) -> None: """Test that devices are added to the device registry.""" - device_registry = dr.async_get(hass) device1 = device_registry.async_get_device(identifiers={(DOMAIN, "5965394")}) assert device1 is not None @@ -26,10 +28,12 @@ def test_zones_in_device_registry( def test_controller_in_device_registry( - hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, + mock_pydrawise: Mock, ) -> None: """Test that devices are added to the device registry.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "52496")}) assert device is not None assert device.name == "Home Controller" diff --git a/tests/components/hyperion/test_camera.py b/tests/components/hyperion/test_camera.py index a6234f34593..e087b0fc1a5 100644 --- a/tests/components/hyperion/test_camera.py +++ b/tests/components/hyperion/test_camera.py @@ -177,7 +177,11 @@ async def test_camera_stream_failed_start_stream_call(hass: HomeAssistant) -> No assert not client.async_send_image_stream_stop.called -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" client = create_mock_client() @@ -190,7 +194,6 @@ async def test_device_info(hass: HomeAssistant) -> None: await setup_test_config_entry(hass, hyperion_client=client) device_id = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device @@ -200,7 +203,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert device.model == HYPERION_MODEL_NAME assert device.name == TEST_INSTANCE_1["friendly_name"] - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 6c4cc4e512e..01cc1c7d9d2 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -114,10 +114,11 @@ async def test_setup_config_entry_not_ready_load_state_fail( assert hass.states.get(TEST_ENTITY_ID_1) is None -async def test_setup_config_entry_dynamic_instances(hass: HomeAssistant) -> None: +async def test_setup_config_entry_dynamic_instances( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test dynamic changes in the instance configuration.""" - registry = er.async_get(hass) - config_entry = add_test_config_entry(hass) master_client = create_mock_client() @@ -164,7 +165,7 @@ async def test_setup_config_entry_dynamic_instances(hass: HomeAssistant) -> None assert hass.states.get(TEST_ENTITY_ID_3) is not None # Instance 1 is stopped, it should still be registered. - assert registry.async_is_registered(TEST_ENTITY_ID_1) + assert entity_registry.async_is_registered(TEST_ENTITY_ID_1) # == Inject a new instances update (remove instance 1) assert master_client.set_callbacks.called @@ -188,7 +189,7 @@ async def test_setup_config_entry_dynamic_instances(hass: HomeAssistant) -> None assert hass.states.get(TEST_ENTITY_ID_3) is not None # Instance 1 is removed, it should not still be registered. - assert not registry.async_is_registered(TEST_ENTITY_ID_1) + assert not entity_registry.async_is_registered(TEST_ENTITY_ID_1) # == Inject a new instances update (re-add instance 1, but not running) with patch( @@ -766,14 +767,17 @@ async def test_light_option_effect_hide_list(hass: HomeAssistant) -> None: ] -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" client = create_mock_client() await setup_test_config_entry(hass, hyperion_client=client) device_id = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device @@ -783,7 +787,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert device.model == HYPERION_MODEL_NAME assert device.name == TEST_INSTANCE_1["friendly_name"] - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index dcdd86f0902..79b9454e29f 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -144,7 +144,11 @@ async def test_switch_has_correct_entities(hass: HomeAssistant) -> None: assert entity_state, f"Couldn't find entity: {entity_id}" -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" client = create_mock_client() client.components = TEST_COMPONENTS @@ -162,7 +166,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) is not None device_identifer = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, device_identifer)}) assert device @@ -172,7 +175,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert device.model == HYPERION_MODEL_NAME assert device.name == TEST_INSTANCE_1["friendly_name"] - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) @@ -184,14 +186,14 @@ async def test_device_info(hass: HomeAssistant) -> None: assert entity_id in entities_from_device -async def test_switches_can_be_enabled(hass: HomeAssistant) -> None: +async def test_switches_can_be_enabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Verify switches can be enabled.""" client = create_mock_client() client.components = TEST_COMPONENTS await setup_test_config_entry(hass, hyperion_client=client) - entity_registry = er.async_get(hass) - for component in TEST_COMPONENTS: name = slugify(KEY_COMPONENTID_TO_NAME[str(component["name"])]) entity_id = TEST_SWITCH_COMPONENT_BASE_ENTITY_ID + "_" + name From 5ee62f29659922960f9572243e8d553a91e4ba84 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 12 Nov 2023 20:25:34 +0100 Subject: [PATCH 418/982] Update nibe heatpump to 2.5.0 (#103788) * Update nibe heatpump to 2.5.0 * Adjust for typing changes in lib * If we use float value, we assume it's valid --- homeassistant/components/nibe_heatpump/climate.py | 3 ++- homeassistant/components/nibe_heatpump/coordinator.py | 6 +++--- homeassistant/components/nibe_heatpump/manifest.json | 2 +- homeassistant/components/nibe_heatpump/number.py | 2 +- homeassistant/components/nibe_heatpump/water_heater.py | 4 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 6280994bd7d..38a3a5f825c 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -1,6 +1,7 @@ """The Nibe Heat Pump climate.""" from __future__ import annotations +from datetime import date from typing import Any from nibe.coil import Coil @@ -124,7 +125,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): @callback def _handle_coordinator_update(self) -> None: - def _get_value(coil: Coil) -> int | str | float | None: + def _get_value(coil: Coil) -> int | str | float | date | None: return self.coordinator.get_coil_value(coil) def _get_float(coil: Coil) -> float | None: diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index 853da6e5232..ce75247083b 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict from collections.abc import Callable, Iterable -from datetime import timedelta +from datetime import date, timedelta from typing import Any, Generic, TypeVar from nibe.coil import Coil, CoilData @@ -123,7 +123,7 @@ class Coordinator(ContextCoordinator[dict[int, CoilData], int]): """Return device information for the main device.""" return DeviceInfo(identifiers={(DOMAIN, self.unique_id)}) - def get_coil_value(self, coil: Coil) -> int | str | float | None: + def get_coil_value(self, coil: Coil) -> int | str | float | date | None: """Return a coil with data and check for validity.""" if coil_with_data := self.data.get(coil.address): return coil_with_data.value @@ -132,7 +132,7 @@ class Coordinator(ContextCoordinator[dict[int, CoilData], int]): def get_coil_float(self, coil: Coil) -> float | None: """Return a coil with float and check for validity.""" if value := self.get_coil_value(coil): - return float(value) + return float(value) # type: ignore[arg-type] return None async def async_write_coil(self, coil: Coil, value: int | float | str) -> None: diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 355ce84525f..73c4dc51089 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.4.0"] + "requirements": ["nibe==2.5.0"] } diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index addfacf4faf..83ccc124e51 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -66,7 +66,7 @@ class Number(CoilEntity, NumberEntity): return try: - self._attr_native_value = float(data.value) + self._attr_native_value = float(data.value) # type: ignore[arg-type] except ValueError: self._attr_native_value = None diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index c9d1d89c6ca..db688fdb69c 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -1,6 +1,8 @@ """The Nibe Heat Pump sensors.""" from __future__ import annotations +from datetime import date + from nibe.coil import Coil from nibe.coil_groups import WATER_HEATER_COILGROUPS, WaterHeaterCoilGroup from nibe.exceptions import CoilNotFoundException @@ -132,7 +134,7 @@ class WaterHeater(CoordinatorEntity[Coordinator], WaterHeaterEntity): return None return self.coordinator.get_coil_float(coil) - def _get_value(coil: Coil | None) -> int | str | float | None: + def _get_value(coil: Coil | None) -> int | str | float | date | None: if coil is None: return None return self.coordinator.get_coil_value(coil) diff --git a/requirements_all.txt b/requirements_all.txt index 24d20a524f5..3b60652cb68 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1311,7 +1311,7 @@ nextcord==2.0.0a8 nextdns==2.0.1 # homeassistant.components.nibe_heatpump -nibe==2.4.0 +nibe==2.5.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04270c17f0d..b147b4d3186 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1025,7 +1025,7 @@ nextcord==2.0.0a8 nextdns==2.0.1 # homeassistant.components.nibe_heatpump -nibe==2.4.0 +nibe==2.5.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From f94167a4bbf3e5620fd5ba1e31e6794df90052dd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 12 Nov 2023 21:13:38 +0100 Subject: [PATCH 419/982] Fix invalid oauth2_unauthorized translation ref (#103873) * Fix invalids oauth2_unauthorized translation ref * Fix oauth2_failed --- homeassistant/components/electric_kiwi/strings.json | 4 ++-- homeassistant/components/fitbit/strings.json | 4 ++-- homeassistant/components/geocaching/strings.json | 4 ++-- homeassistant/components/google/strings.json | 4 ++-- homeassistant/components/google_assistant_sdk/strings.json | 4 ++-- homeassistant/components/google_mail/strings.json | 4 ++-- homeassistant/components/google_sheets/strings.json | 4 ++-- homeassistant/components/google_tasks/strings.json | 4 ++-- homeassistant/components/home_connect/strings.json | 4 ++-- homeassistant/components/home_plus_control/strings.json | 4 ++-- homeassistant/components/lametric/strings.json | 4 ++-- homeassistant/components/lyric/strings.json | 4 ++-- homeassistant/components/neato/strings.json | 4 ++-- homeassistant/components/nest/strings.json | 4 ++-- homeassistant/components/netatmo/strings.json | 4 ++-- homeassistant/components/ondilo_ico/strings.json | 4 ++-- homeassistant/components/senz/strings.json | 4 ++-- homeassistant/components/smappee/strings.json | 4 ++-- homeassistant/components/spotify/strings.json | 4 ++-- homeassistant/components/toon/strings.json | 4 ++-- homeassistant/components/twitch/strings.json | 4 ++-- homeassistant/components/withings/strings.json | 4 ++-- homeassistant/components/xbox/strings.json | 4 ++-- homeassistant/components/yolink/strings.json | 4 ++-- homeassistant/components/youtube/strings.json | 4 ++-- script/scaffold/generate.py | 4 ++-- 26 files changed, 52 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 4a67bd5211b..d21c0d80ca6 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -19,8 +19,8 @@ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index d941121e4da..e1ca1b01f7a 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -24,8 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "wrong_account": "The user credentials provided do not match this Fitbit account.", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index fd431860cd2..9989af9a75c 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -18,8 +18,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 9327009bda3..4e62b134b0e 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -24,8 +24,8 @@ "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index fa86e207a9c..d5d1d885427 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -23,8 +23,8 @@ "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "unknown": "[%key:common::config_flow::error::unknown%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 3ed1c2377d5..142e8f039d2 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -24,8 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "wrong_account": "Wrong account: Please authenticate with {email}.", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index ea327097d88..e498e36723e 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -23,8 +23,8 @@ "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "Successfully authenticated and spreadsheet created at: {url}" diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index d730f4cb770..2cf15f0d93d 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -19,8 +19,8 @@ "access_not_configured": "Unable to access the Google API:\n\n{message}", "unknown": "[%key:common::config_flow::error::unknown%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7ee44089b28..8afd3aaf8ce 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -10,8 +10,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index c35650a5183..13a7102827c 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -14,8 +14,8 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 4069cb41bdd..87bda01e305 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -44,8 +44,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } }, "entity": { diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 6b594654dfa..68bb6292f9e 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -15,8 +15,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index 3dcceecd1e3..6a442e7c353 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -16,8 +16,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index a54ac82a9a7..35e1cc68165 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -52,8 +52,8 @@ "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 99f780dbe3e..bdb51808852 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -17,8 +17,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/ondilo_ico/strings.json b/homeassistant/components/ondilo_ico/strings.json index 7b049e66ae2..26199b1bd75 100644 --- a/homeassistant/components/ondilo_ico/strings.json +++ b/homeassistant/components/ondilo_ico/strings.json @@ -10,8 +10,8 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/senz/strings.json b/homeassistant/components/senz/strings.json index 693cfe3415b..cb1f056d72d 100644 --- a/homeassistant/components/senz/strings.json +++ b/homeassistant/components/senz/strings.json @@ -13,8 +13,8 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 9322170dfdf..2bdbf0dabe8 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -32,8 +32,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } } } diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index b53b600d5ba..02077cbdb43 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -16,8 +16,8 @@ "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication.", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "Successfully authenticated with Spotify." diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index fb05a15db00..ed29e77a58c 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -21,8 +21,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } }, "services": { diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json index 3bda5284c0f..f4128a15adc 100644 --- a/homeassistant/components/twitch/strings.json +++ b/homeassistant/components/twitch/strings.json @@ -13,8 +13,8 @@ "wrong_account": "Wrong account: Please authenticate with {username}.", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } }, "issues": { diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 645ab135300..fc24c1f5325 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -19,8 +19,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "Successfully authenticated with Withings." diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index e011194dc7c..0d9a12137ce 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -11,8 +11,8 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 07df1008653..212d7ced7d7 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -18,8 +18,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 0bd62a42314..d664e2f15e7 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -9,8 +9,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index ff503bc12db..197c36e22d1 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -185,9 +185,9 @@ def _custom_tasks(template, info: Info) -> None: "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", From efe33d815f387ed0fa6b52deb09e7e6c34dd5563 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 12 Nov 2023 21:36:54 +0100 Subject: [PATCH 420/982] Address late proximity coordinator review comments (#103879) --- .../components/proximity/__init__.py | 14 ++++- .../components/proximity/coordinator.py | 59 ++++++++----------- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index d2db7632b52..4012d6e8ea1 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -47,12 +48,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data.setdefault(DOMAIN, {}) for zone, proximity_config in config[DOMAIN].items(): _LOGGER.debug("setup %s with config:%s", zone, proximity_config) + coordinator = ProximityDataUpdateCoordinator(hass, zone, proximity_config) - await coordinator.async_config_entry_first_refresh() + + async_track_state_change( + hass, + proximity_config[CONF_DEVICES], + coordinator.async_check_proximity_state_change, + ) + + await coordinator.async_refresh() hass.data[DOMAIN][zone] = coordinator + proximity = Proximity(hass, zone, coordinator) - proximity.async_write_ha_state() await proximity.async_added_to_hass() + proximity.async_write_ha_state() return True diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 1b5770378dd..1f1c96c9490 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -13,7 +13,6 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.location import distance @@ -77,9 +76,6 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): } self.state_change_data: StateChangedData | None = None - async_track_state_change( - hass, self.proximity_devices, self.async_check_proximity_state_change - ) async def async_check_proximity_state_change( self, entity: str, old_state: State | None, new_state: State | None @@ -89,22 +85,19 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): _LOGGER.debug("no new_state -> abort") return - # We can't check proximity because latitude and longitude don't exist. - if "latitude" not in new_state.attributes: - _LOGGER.debug("no latitude and longitude -> abort") - return - self.state_change_data = StateChangedData(entity, old_state, new_state) await self.async_refresh() async def _async_update_data(self) -> ProximityData: """Calculate Proximity data.""" - if self.state_change_data is None or self.state_change_data.new_state is None: + if ( + state_change_data := self.state_change_data + ) is None or state_change_data.new_state is None: return self.data - entity_name = self.state_change_data.new_state.name + entity_name = state_change_data.new_state.name devices_to_calculate = False - devices_in_zone = "" + devices_in_zone = [] zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") proximity_latitude = ( @@ -126,9 +119,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): # Check the location of all devices. if (device_state.state).lower() == (self.proximity_zone).lower(): device_friendly = device_state.name - if devices_in_zone != "": - devices_in_zone = f"{devices_in_zone}, " - devices_in_zone = devices_in_zone + device_friendly + devices_in_zone.append(device_friendly) # No-one to track so reset the entity. if not devices_to_calculate: @@ -140,14 +131,19 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): } # At least one device is in the monitored zone so update the entity. - if devices_in_zone != "": - _LOGGER.debug("at least on device is in zone -> arrived") + if devices_in_zone: + _LOGGER.debug("at least one device is in zone -> arrived") return { "dist_to_zone": 0, "dir_of_travel": "arrived", - "nearest": devices_in_zone, + "nearest": ", ".join(devices_in_zone), } + # We can't check proximity because latitude and longitude don't exist. + if "latitude" not in state_change_data.new_state.attributes: + _LOGGER.debug("no latitude and longitude -> reset") + return self.data + # Collect distances to the zone for all devices. distances_to_zone: dict[str, float] = {} for device in self.proximity_devices: @@ -169,7 +165,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) # Add the device and distance to a dictionary. - if not proximity: + if proximity is None: continue distances_to_zone[device] = round( DistanceConverter.convert( @@ -189,10 +185,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): dist_to_zone = zone # If the closest device is one of the other devices. - if ( - closest_device is not None - and closest_device != self.state_change_data.entity_id - ): + if closest_device is not None and closest_device != state_change_data.entity_id: _LOGGER.debug("closest device is one of the other devices -> unknown") device_state = self.hass.states.get(closest_device) assert device_state @@ -205,14 +198,12 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): # Stop if we cannot calculate the direction of travel (i.e. we don't # have a previous state and a current LAT and LONG). if ( - self.state_change_data.old_state is None - or "latitude" not in self.state_change_data.old_state.attributes + state_change_data.old_state is None + or "latitude" not in state_change_data.old_state.attributes ): _LOGGER.debug("no lat and lon in old_state -> unknown") return { - "dist_to_zone": round( - distances_to_zone[self.state_change_data.entity_id] - ), + "dist_to_zone": round(distances_to_zone[state_change_data.entity_id]), "dir_of_travel": "unknown", "nearest": entity_name, } @@ -224,14 +215,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): old_distance = distance( proximity_latitude, proximity_longitude, - self.state_change_data.old_state.attributes[ATTR_LATITUDE], - self.state_change_data.old_state.attributes[ATTR_LONGITUDE], + state_change_data.old_state.attributes[ATTR_LATITUDE], + state_change_data.old_state.attributes[ATTR_LONGITUDE], ) new_distance = distance( proximity_latitude, proximity_longitude, - self.state_change_data.new_state.attributes[ATTR_LATITUDE], - self.state_change_data.new_state.attributes[ATTR_LONGITUDE], + state_change_data.new_state.attributes[ATTR_LATITUDE], + state_change_data.new_state.attributes[ATTR_LONGITUDE], ) assert new_distance is not None and old_distance is not None distance_travelled = round(new_distance - old_distance, 1) @@ -252,15 +243,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): dist_to = DEFAULT_DIST_TO_ZONE _LOGGER.debug( - "proximity.%s update entity: distance=%s: direction=%s: device=%s", + "%s updated: distance=%s: direction=%s: device=%s", self.friendly_name, dist_to, direction_of_travel, entity_name, ) - _LOGGER.info("%s: proximity calculation complete", entity_name) - return { "dist_to_zone": dist_to, "dir_of_travel": direction_of_travel, From 64c9aa0cff6c0f7db6effe131fddfdcbf9827338 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Nov 2023 12:49:49 -0800 Subject: [PATCH 421/982] Update Fitbit to avoid a KeyError when `restingHeartRate` is not present (#103872) * Update Fitbit to avoid a KeyError when `restingHeartRate` is not present * Explicitly handle none response values --- homeassistant/components/fitbit/sensor.py | 13 +++++- tests/components/fitbit/test_sensor.py | 57 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 336a6620035..e2cfb3e3992 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -135,6 +135,17 @@ def _water_unit(unit_system: FitbitUnitSystem) -> UnitOfVolume: return UnitOfVolume.MILLILITERS +def _int_value_or_none(field: str) -> Callable[[dict[str, Any]], int | None]: + """Value function that will parse the specified field if present.""" + + def convert(result: dict[str, Any]) -> int | None: + if (value := result["value"].get(field)) is not None: + return int(value) + return None + + return convert + + @dataclass class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" @@ -207,7 +218,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Resting Heart Rate", native_unit_of_measurement="bpm", icon="mdi:heart-pulse", - value_fn=lambda result: int(result["value"]["restingHeartRate"]), + value_fn=_int_value_or_none("restingHeartRate"), scope=FitbitScope.HEART_RATE, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 08c9761bce2..871088eae63 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -808,3 +808,60 @@ async def test_device_battery_level_reauth_required( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.parametrize( + ("scopes", "response_data", "expected_state"), + [ + (["heartrate"], {}, "unknown"), + ( + ["heartrate"], + { + "restingHeartRate": 120, + }, + "120", + ), + ( + ["heartrate"], + { + "restingHeartRate": 0, + }, + "0", + ), + ], + ids=("missing", "valid", "zero"), +) +async def test_resting_heart_rate_responses( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + response_data: dict[str, Any], + expected_state: str, +) -> None: + """Test resting heart rate sensor with various values from response.""" + + register_timeseries( + "activities/heart", + timeseries_response( + "activities-heart", + { + "customHeartRateZones": [], + "heartRateZones": [ + { + "caloriesOut": 0, + "max": 220, + "min": 159, + "minutes": 0, + "name": "Peak", + }, + ], + **response_data, + }, + ), + ) + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == expected_state From 188831180085d51401e5f80fa76aee62dead7b0a Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 12 Nov 2023 17:09:08 -0500 Subject: [PATCH 422/982] Hydrawise: Explicitly set switch state on toggle (#103827) Explicitly set switch state on toggle --- homeassistant/components/hydrawise/switch.py | 4 ++++ tests/components/hydrawise/test_switch.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index caaefd7aa26..2aa4ecc085b 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -114,6 +114,8 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): self.coordinator.api.run_zone(self._default_watering_timer, zone_number) elif self.entity_description.key == "auto_watering": self.coordinator.api.suspend_zone(0, zone_number) + self._attr_is_on = True + self.async_write_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" @@ -122,6 +124,8 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): self.coordinator.api.run_zone(0, zone_number) elif self.entity_description.key == "auto_watering": self.coordinator.api.suspend_zone(365, zone_number) + self._attr_is_on = False + self.async_write_ha_state() def _update_attrs(self) -> None: """Update state attributes.""" diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index 39d789f4cf9..1d2de7f8332 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -45,6 +45,9 @@ async def test_manual_watering_services( blocking=True, ) mock_pydrawise.run_zone.assert_called_once_with(15, 1) + state = hass.states.get("switch.zone_one_manual_watering") + assert state is not None + assert state.state == "on" mock_pydrawise.reset_mock() await hass.services.async_call( @@ -54,6 +57,9 @@ async def test_manual_watering_services( blocking=True, ) mock_pydrawise.run_zone.assert_called_once_with(0, 1) + state = hass.states.get("switch.zone_one_manual_watering") + assert state is not None + assert state.state == "off" async def test_auto_watering_services( @@ -67,6 +73,9 @@ async def test_auto_watering_services( blocking=True, ) mock_pydrawise.suspend_zone.assert_called_once_with(365, 1) + state = hass.states.get("switch.zone_one_automatic_watering") + assert state is not None + assert state.state == "off" mock_pydrawise.reset_mock() await hass.services.async_call( @@ -76,3 +85,6 @@ async def test_auto_watering_services( blocking=True, ) mock_pydrawise.suspend_zone.assert_called_once_with(0, 1) + state = hass.states.get("switch.zone_one_automatic_watering") + assert state is not None + assert state.state == "on" From 50a65fc8c4438638fa3aa29a0ca0ad95dfd1158f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Nov 2023 17:12:58 -0600 Subject: [PATCH 423/982] Bump zeroconf to 0.125.0 (#103877) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 00f81be0793..7b47b854bd1 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.123.0"] + "requirements": ["zeroconf==0.125.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 188fe02698b..ebbe9686044 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -56,7 +56,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.123.0 +zeroconf==0.125.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 3b60652cb68..3d127d30b15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2803,7 +2803,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.123.0 +zeroconf==0.125.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b147b4d3186..5ece8f95a86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2094,7 +2094,7 @@ yt-dlp==2023.10.13 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.123.0 +zeroconf==0.125.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From d0efea3dbd00188363bfd6f3200d8aef8826ae4f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Nov 2023 07:25:58 +0100 Subject: [PATCH 424/982] Add tests for configuration validation errors (#103848) * Add tests for configuration validation errors * Use absolute path for hass.config.config_dir * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare --- .../core/config/basic/configuration.yaml | 21 +++++ .../config/basic_include/configuration.yaml | 4 + .../integrations/adr_0007_1.yaml | 2 + .../integrations/adr_0007_2.yaml | 1 + .../integrations/adr_0007_3.yaml | 3 + .../integrations/iot_domain.yaml | 8 ++ .../include_dir_list/configuration.yaml | 1 + .../iot_domain/iot_domain_1.yaml | 3 + .../iot_domain/iot_domain_2.yaml | 3 + .../iot_domain/iot_domain_3.yaml | 2 + .../include_dir_merge_list/configuration.yaml | 1 + .../iot_domain/iot_domain_1.yaml | 3 + .../iot_domain/iot_domain_2.yaml | 5 + .../core/config/packages/configuration.yaml | 28 ++++++ .../configuration.yaml | 3 + .../integrations/adr_0007_1.yaml | 3 + .../integrations/adr_0007_2.yaml | 2 + .../integrations/adr_0007_3.yaml | 4 + .../integrations/iot_domain.yaml | 9 ++ tests/snapshots/test_config.ambr | 45 +++++++++ tests/test_config.py | 91 ++++++++++++++++++- 21 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/core/config/basic/configuration.yaml create mode 100644 tests/fixtures/core/config/basic_include/configuration.yaml create mode 100644 tests/fixtures/core/config/basic_include/integrations/adr_0007_1.yaml create mode 100644 tests/fixtures/core/config/basic_include/integrations/adr_0007_2.yaml create mode 100644 tests/fixtures/core/config/basic_include/integrations/adr_0007_3.yaml create mode 100644 tests/fixtures/core/config/basic_include/integrations/iot_domain.yaml create mode 100644 tests/fixtures/core/config/include_dir_list/configuration.yaml create mode 100644 tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_1.yaml create mode 100644 tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_2.yaml create mode 100644 tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_3.yaml create mode 100644 tests/fixtures/core/config/include_dir_merge_list/configuration.yaml create mode 100644 tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_1.yaml create mode 100644 tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml create mode 100644 tests/fixtures/core/config/packages/configuration.yaml create mode 100644 tests/fixtures/core/config/packages_include_dir_named/configuration.yaml create mode 100644 tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_1.yaml create mode 100644 tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_2.yaml create mode 100644 tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_3.yaml create mode 100644 tests/fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml create mode 100644 tests/snapshots/test_config.ambr diff --git a/tests/fixtures/core/config/basic/configuration.yaml b/tests/fixtures/core/config/basic/configuration.yaml new file mode 100644 index 00000000000..5b3aacd9523 --- /dev/null +++ b/tests/fixtures/core/config/basic/configuration.yaml @@ -0,0 +1,21 @@ +iot_domain: + # This is correct and should not generate errors + - platform: non_adr_0007 + option1: abc + # This violates the non_adr_0007.iot_domain platform schema + - platform: non_adr_0007 + option1: 123 + # This violates the iot_domain platform schema + - paltfrom: non_adr_0007 + +# This is correct and should not generate errors +adr_0007_1: + host: blah.com + +# Host is missing +adr_0007_2: + +# Port is wrong type +adr_0007_3: + host: blah.com + port: foo diff --git a/tests/fixtures/core/config/basic_include/configuration.yaml b/tests/fixtures/core/config/basic_include/configuration.yaml new file mode 100644 index 00000000000..ab86a6b34da --- /dev/null +++ b/tests/fixtures/core/config/basic_include/configuration.yaml @@ -0,0 +1,4 @@ +iot_domain: !include integrations/iot_domain.yaml +adr_0007_1: !include integrations/adr_0007_1.yaml +adr_0007_2: !include integrations/adr_0007_2.yaml +adr_0007_3: !include integrations/adr_0007_3.yaml diff --git a/tests/fixtures/core/config/basic_include/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/basic_include/integrations/adr_0007_1.yaml new file mode 100644 index 00000000000..d246d73c257 --- /dev/null +++ b/tests/fixtures/core/config/basic_include/integrations/adr_0007_1.yaml @@ -0,0 +1,2 @@ +# This is correct and should not generate errors +host: blah.com diff --git a/tests/fixtures/core/config/basic_include/integrations/adr_0007_2.yaml b/tests/fixtures/core/config/basic_include/integrations/adr_0007_2.yaml new file mode 100644 index 00000000000..8b592b01e2d --- /dev/null +++ b/tests/fixtures/core/config/basic_include/integrations/adr_0007_2.yaml @@ -0,0 +1 @@ +# Host is missing diff --git a/tests/fixtures/core/config/basic_include/integrations/adr_0007_3.yaml b/tests/fixtures/core/config/basic_include/integrations/adr_0007_3.yaml new file mode 100644 index 00000000000..c3b2edb3f94 --- /dev/null +++ b/tests/fixtures/core/config/basic_include/integrations/adr_0007_3.yaml @@ -0,0 +1,3 @@ +# Port is wrong type +host: blah.com +port: foo diff --git a/tests/fixtures/core/config/basic_include/integrations/iot_domain.yaml b/tests/fixtures/core/config/basic_include/integrations/iot_domain.yaml new file mode 100644 index 00000000000..405fc3aab91 --- /dev/null +++ b/tests/fixtures/core/config/basic_include/integrations/iot_domain.yaml @@ -0,0 +1,8 @@ +# This is correct and should not generate errors +- platform: non_adr_0007 + option1: abc +# This violates the non_adr_0007.iot_domain platform schema +- platform: non_adr_0007 + option1: 123 +# This violates the iot_domain platform schema +- paltfrom: non_adr_0007 diff --git a/tests/fixtures/core/config/include_dir_list/configuration.yaml b/tests/fixtures/core/config/include_dir_list/configuration.yaml new file mode 100644 index 00000000000..bb0f052a39a --- /dev/null +++ b/tests/fixtures/core/config/include_dir_list/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include_dir_list iot_domain diff --git a/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_1.yaml new file mode 100644 index 00000000000..b17f6106208 --- /dev/null +++ b/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_1.yaml @@ -0,0 +1,3 @@ +# This is correct and should not generate errors +platform: non_adr_0007 +option1: abc diff --git a/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_2.yaml new file mode 100644 index 00000000000..f4d009c8cfa --- /dev/null +++ b/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_2.yaml @@ -0,0 +1,3 @@ +# This violates the non_adr_0007.iot_domain platform schema +platform: non_adr_0007 +option1: 123 diff --git a/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_3.yaml b/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_3.yaml new file mode 100644 index 00000000000..94c18721061 --- /dev/null +++ b/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_3.yaml @@ -0,0 +1,2 @@ +# This violates the iot_domain platform schema +paltfrom: non_adr_0007 diff --git a/tests/fixtures/core/config/include_dir_merge_list/configuration.yaml b/tests/fixtures/core/config/include_dir_merge_list/configuration.yaml new file mode 100644 index 00000000000..e0c03e9f445 --- /dev/null +++ b/tests/fixtures/core/config/include_dir_merge_list/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include_dir_merge_list iot_domain diff --git a/tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_1.yaml new file mode 100644 index 00000000000..a0636cdecf4 --- /dev/null +++ b/tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_1.yaml @@ -0,0 +1,3 @@ +# This is correct and should not generate errors +- platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml new file mode 100644 index 00000000000..16df25adcd7 --- /dev/null +++ b/tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml @@ -0,0 +1,5 @@ +# This violates the non_adr_0007.iot_domain platform schema +- platform: non_adr_0007 + option1: 123 + # This violates the iot_domain platform schema +- paltfrom: non_adr_0007 diff --git a/tests/fixtures/core/config/packages/configuration.yaml b/tests/fixtures/core/config/packages/configuration.yaml new file mode 100644 index 00000000000..5b3cf74615a --- /dev/null +++ b/tests/fixtures/core/config/packages/configuration.yaml @@ -0,0 +1,28 @@ +homeassistant: + packages: + pack_1: + iot_domain: + # This is correct and should not generate errors + - platform: non_adr_0007 + option1: abc + pack_2: + iot_domain: + # This violates the non_adr_0007.iot_domain platform schema + - platform: non_adr_0007 + option1: 123 + pack_3: + iot_domain: + # This violates the iot_domain platform schema + - paltfrom: non_adr_0007 + pack_4: + # This is correct and should not generate errors + adr_0007_1: + host: blah.com + pack_5: + # Host is missing + adr_0007_2: + pack_6: + # Port is wrong type + adr_0007_3: + host: blah.com + port: foo diff --git a/tests/fixtures/core/config/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/packages_include_dir_named/configuration.yaml new file mode 100644 index 00000000000..d3b52e4d49d --- /dev/null +++ b/tests/fixtures/core/config/packages_include_dir_named/configuration.yaml @@ -0,0 +1,3 @@ +homeassistant: + # Load packages + packages: !include_dir_named integrations diff --git a/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_1.yaml new file mode 100644 index 00000000000..c07a9434f82 --- /dev/null +++ b/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_1.yaml @@ -0,0 +1,3 @@ +# This is correct and should not generate errors +adr_0007_1: + host: blah.com diff --git a/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_2.yaml b/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_2.yaml new file mode 100644 index 00000000000..0f96654008e --- /dev/null +++ b/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_2.yaml @@ -0,0 +1,2 @@ +# Host is missing +adr_0007_2: diff --git a/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_3.yaml b/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_3.yaml new file mode 100644 index 00000000000..1ad33e67171 --- /dev/null +++ b/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_3.yaml @@ -0,0 +1,4 @@ +# Port is wrong type +adr_0007_3: + host: blah.com + port: foo diff --git a/tests/fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml b/tests/fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml new file mode 100644 index 00000000000..8c366297165 --- /dev/null +++ b/tests/fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml @@ -0,0 +1,9 @@ +iot_domain: + # This is correct and should not generate errors + - platform: non_adr_0007 + option1: abc + # This violates the non_adr_0007.iot_domain platform schema + - platform: non_adr_0007 + option1: 123 + # This violates the iot_domain platform schema + - paltfrom: non_adr_0007 diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr new file mode 100644 index 00000000000..5a8831e2140 --- /dev/null +++ b/tests/snapshots/test_config.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_component_config_validation_error[basic] + list([ + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/basic/configuration.yaml, line 6). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/basic/configuration.yaml, line 9). ", + "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/basic/configuration.yaml, line 20). ", + ]) +# --- +# name: test_component_config_validation_error[basic_include] + list([ + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/basic_include/integrations/iot_domain.yaml, line 5). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/basic_include/integrations/iot_domain.yaml, line 8). ", + "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/basic_include/configuration.yaml, line 4). ", + ]) +# --- +# name: test_component_config_validation_error[include_dir_list] + list([ + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/include_dir_list/iot_domain/iot_domain_2.yaml, line 2). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/include_dir_list/iot_domain/iot_domain_3.yaml, line 2). ", + ]) +# --- +# name: test_component_config_validation_error[include_dir_merge_list] + list([ + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 2). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5). ", + ]) +# --- +# name: test_component_config_validation_error[packages] + list([ + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/packages/configuration.yaml, line 11). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/packages/configuration.yaml, line 16). ", + "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?). ", + ]) +# --- +# name: test_component_config_validation_error[packages_include_dir_named] + list([ + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml, line 6). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml, line 9). ", + "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?). ", + ]) +# --- diff --git a/tests/test_config.py b/tests/test_config.py index d5181bbe115..83ab670387c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,12 +2,14 @@ from collections import OrderedDict import contextlib import copy +import logging import os from typing import Any from unittest import mock from unittest.mock import AsyncMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from voluptuous import Invalid, MultipleInvalid import yaml @@ -40,7 +42,14 @@ from homeassistant.util.unit_system import ( ) from homeassistant.util.yaml import SECRET_YAML -from .common import MockUser, get_test_config_dir +from .common import ( + MockModule, + MockPlatform, + MockUser, + get_test_config_dir, + mock_integration, + mock_platform, +) CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) @@ -1399,3 +1408,83 @@ async def test_safe_mode(hass: HomeAssistant) -> None: await config_util.async_enable_safe_mode(hass) assert config_util.safe_mode_enabled(hass.config.config_dir) is True assert config_util.safe_mode_enabled(hass.config.config_dir) is False + + +@pytest.mark.parametrize( + "config_dir", + [ + "basic", + "basic_include", + "include_dir_list", + "include_dir_merge_list", + "packages", + "packages_include_dir_named", + ], +) +async def test_component_config_validation_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + snapshot: SnapshotAssertion, +) -> None: + """Test schema error in component.""" + comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) + + # Mock an integration which provides an IoT domain + mock_integration( + hass, + MockModule( + "iot_domain", + platform_schema_base=comp_platform_schema_base, + platform_schema=comp_platform_schema, + ), + ) + + # Mock a non-ADR-0007 compliant integration which allows setting up + # iot_domain entities under the iot_domain's configuration key + test_platform_schema = comp_platform_schema.extend({"option1": str}) + mock_platform( + hass, + "non_adr_0007.iot_domain", + MockPlatform(platform_schema=test_platform_schema), + ) + + # Mock an ADR-0007 compliant integration + for domain in ["adr_0007_1", "adr_0007_2", "adr_0007_3"]: + adr_0007_config_schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Required("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + mock_integration( + hass, + MockModule(domain, config_schema=adr_0007_config_schema), + ) + + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", config_dir + ) + config = await config_util.async_hass_config_yaml(hass) + + for domain in ["iot_domain", "adr_0007_1", "adr_0007_2", "adr_0007_3"]: + integration = await async_get_integration(hass, domain) + await config_util.async_process_component_config( + hass, + config, + integration=integration, + ) + + error_records = [ + record.message.replace(base_path, "") + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot From 74bf255da66923c634556de97f25448278723bd4 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Mon, 13 Nov 2023 10:37:49 +0100 Subject: [PATCH 425/982] Bump lupupy to 0.3.1 (#103835) Co-authored-by: suaveolent --- homeassistant/components/lupusec/binary_sensor.py | 2 +- homeassistant/components/lupusec/manifest.json | 2 +- homeassistant/components/lupusec/switch.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index 2c6e7b2fff8..c98e634dcb3 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -27,7 +27,7 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - device_types = [CONST.TYPE_OPENING] + device_types = CONST.TYPE_OPENING devices = [] for device in data.lupusec.get_devices(generic_type=device_types): diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 6fa6c55de2e..e73feef55a1 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lupusec", "iot_class": "local_polling", "loggers": ["lupupy"], - "requirements": ["lupupy==0.3.0"] + "requirements": ["lupupy==0.3.1"] } diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 981a2a8633a..37a3b2ec969 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -28,7 +28,7 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - device_types = [CONST.TYPE_SWITCH] + device_types = CONST.TYPE_SWITCH devices = [] for device in data.lupusec.get_devices(generic_type=device_types): diff --git a/requirements_all.txt b/requirements_all.txt index 3d127d30b15..3cb6eaff697 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1197,7 +1197,7 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.lupusec -lupupy==0.3.0 +lupupy==0.3.1 # homeassistant.components.lw12wifi lw12==0.9.2 From be2cee228cc50cd9558a80f7a56b1d5b515ec03b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Nov 2023 11:21:37 +0100 Subject: [PATCH 426/982] Add tests for package errors (#103902) --- .../basic/configuration.yaml | 0 .../basic_include/configuration.yaml | 0 .../integrations/adr_0007_1.yaml | 0 .../integrations/adr_0007_2.yaml | 0 .../integrations/adr_0007_3.yaml | 0 .../integrations/iot_domain.yaml | 0 .../include_dir_list/configuration.yaml | 0 .../iot_domain/iot_domain_1.yaml | 0 .../iot_domain/iot_domain_2.yaml | 0 .../iot_domain/iot_domain_3.yaml | 0 .../include_dir_merge_list/configuration.yaml | 0 .../iot_domain/iot_domain_1.yaml | 0 .../iot_domain/iot_domain_2.yaml | 0 .../packages/configuration.yaml | 0 .../configuration.yaml | 0 .../integrations/adr_0007_1.yaml | 0 .../integrations/adr_0007_2.yaml | 0 .../integrations/adr_0007_3.yaml | 0 .../integrations/iot_domain.yaml | 0 .../packages/configuration.yaml | 21 +++ .../configuration.yaml | 7 + .../integrations/adr_0007_1.yaml | 3 + .../integrations/adr_0007_2.yaml | 3 + .../integrations/adr_0007_3_1.yaml | 3 + .../integrations/adr_0007_3_2.yaml | 2 + tests/snapshots/test_config.ambr | 42 ++++-- tests/test_config.py | 134 ++++++++++++------ 27 files changed, 160 insertions(+), 55 deletions(-) rename tests/fixtures/core/config/{ => component_validation}/basic/configuration.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/basic_include/configuration.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/basic_include/integrations/adr_0007_1.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/basic_include/integrations/adr_0007_2.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/basic_include/integrations/adr_0007_3.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/basic_include/integrations/iot_domain.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/include_dir_list/configuration.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/include_dir_list/iot_domain/iot_domain_1.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/include_dir_list/iot_domain/iot_domain_2.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/include_dir_list/iot_domain/iot_domain_3.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/include_dir_merge_list/configuration.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/include_dir_merge_list/iot_domain/iot_domain_1.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/include_dir_merge_list/iot_domain/iot_domain_2.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/packages/configuration.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/packages_include_dir_named/configuration.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/packages_include_dir_named/integrations/adr_0007_1.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/packages_include_dir_named/integrations/adr_0007_2.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/packages_include_dir_named/integrations/adr_0007_3.yaml (100%) rename tests/fixtures/core/config/{ => component_validation}/packages_include_dir_named/integrations/iot_domain.yaml (100%) create mode 100644 tests/fixtures/core/config/package_errors/packages/configuration.yaml create mode 100644 tests/fixtures/core/config/package_errors/packages_include_dir_named/configuration.yaml create mode 100644 tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml create mode 100644 tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml create mode 100644 tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_1.yaml create mode 100644 tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml diff --git a/tests/fixtures/core/config/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml similarity index 100% rename from tests/fixtures/core/config/basic/configuration.yaml rename to tests/fixtures/core/config/component_validation/basic/configuration.yaml diff --git a/tests/fixtures/core/config/basic_include/configuration.yaml b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml similarity index 100% rename from tests/fixtures/core/config/basic_include/configuration.yaml rename to tests/fixtures/core/config/component_validation/basic_include/configuration.yaml diff --git a/tests/fixtures/core/config/basic_include/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_1.yaml similarity index 100% rename from tests/fixtures/core/config/basic_include/integrations/adr_0007_1.yaml rename to tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_1.yaml diff --git a/tests/fixtures/core/config/basic_include/integrations/adr_0007_2.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_2.yaml similarity index 100% rename from tests/fixtures/core/config/basic_include/integrations/adr_0007_2.yaml rename to tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_2.yaml diff --git a/tests/fixtures/core/config/basic_include/integrations/adr_0007_3.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml similarity index 100% rename from tests/fixtures/core/config/basic_include/integrations/adr_0007_3.yaml rename to tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml diff --git a/tests/fixtures/core/config/basic_include/integrations/iot_domain.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml similarity index 100% rename from tests/fixtures/core/config/basic_include/integrations/iot_domain.yaml rename to tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml diff --git a/tests/fixtures/core/config/include_dir_list/configuration.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/configuration.yaml similarity index 100% rename from tests/fixtures/core/config/include_dir_list/configuration.yaml rename to tests/fixtures/core/config/component_validation/include_dir_list/configuration.yaml diff --git a/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_1.yaml similarity index 100% rename from tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_1.yaml rename to tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_1.yaml diff --git a/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml similarity index 100% rename from tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_2.yaml rename to tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml diff --git a/tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_3.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml similarity index 100% rename from tests/fixtures/core/config/include_dir_list/iot_domain/iot_domain_3.yaml rename to tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml diff --git a/tests/fixtures/core/config/include_dir_merge_list/configuration.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/configuration.yaml similarity index 100% rename from tests/fixtures/core/config/include_dir_merge_list/configuration.yaml rename to tests/fixtures/core/config/component_validation/include_dir_merge_list/configuration.yaml diff --git a/tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml similarity index 100% rename from tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_1.yaml rename to tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml diff --git a/tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml similarity index 100% rename from tests/fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml rename to tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml diff --git a/tests/fixtures/core/config/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml similarity index 100% rename from tests/fixtures/core/config/packages/configuration.yaml rename to tests/fixtures/core/config/component_validation/packages/configuration.yaml diff --git a/tests/fixtures/core/config/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/configuration.yaml similarity index 100% rename from tests/fixtures/core/config/packages_include_dir_named/configuration.yaml rename to tests/fixtures/core/config/component_validation/packages_include_dir_named/configuration.yaml diff --git a/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_1.yaml similarity index 100% rename from tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_1.yaml rename to tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_1.yaml diff --git a/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_2.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml similarity index 100% rename from tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_2.yaml rename to tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml diff --git a/tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_3.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml similarity index 100% rename from tests/fixtures/core/config/packages_include_dir_named/integrations/adr_0007_3.yaml rename to tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml diff --git a/tests/fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml similarity index 100% rename from tests/fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml rename to tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml diff --git a/tests/fixtures/core/config/package_errors/packages/configuration.yaml b/tests/fixtures/core/config/package_errors/packages/configuration.yaml new file mode 100644 index 00000000000..498eca0edac --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages/configuration.yaml @@ -0,0 +1,21 @@ +# adr007_1 should be a dict, this will cause a package error +adr_0007_1: + - host: blah.com + +homeassistant: + packages: + pack_1: + # This is correct, but root config is wrong + adr_0007_1: + port: 8080 + pack_2: + # Should not be a list + adr_0007_2: + - host: blah.com + pack_3: + # Host duplicated in pack_4 + adr_0007_3: + host: blah.com + pack_4: + adr_0007_3: + host: blah.com diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/configuration.yaml new file mode 100644 index 00000000000..85ffc610758 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/configuration.yaml @@ -0,0 +1,7 @@ +# adr007_1 should be a dict, this will cause a package error +adr_0007_1: + - host: blah.com + +homeassistant: + # Load packages + packages: !include_dir_named integrations diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml new file mode 100644 index 00000000000..09cbdaa1bf8 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml @@ -0,0 +1,3 @@ +# This is correct, but root config is wrong +adr_0007_1: + port: 8080 diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml new file mode 100644 index 00000000000..c1ab9d84c48 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml @@ -0,0 +1,3 @@ +# Should not be a list +adr_0007_2: + - host: blah.com diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_1.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_1.yaml new file mode 100644 index 00000000000..1b524ae6ec1 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_1.yaml @@ -0,0 +1,3 @@ +# Host duplicated in adr_0007_3_2.yaml +adr_0007_3: + host: blah.com diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml new file mode 100644 index 00000000000..5e28092d6c0 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml @@ -0,0 +1,2 @@ +adr_0007_3: + host: blah.com diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 5a8831e2140..b94116f7bca 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,45 +1,59 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/basic/configuration.yaml, line 6). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/basic/configuration.yaml, line 9). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 6). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 9). ", "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/basic/configuration.yaml, line 20). ", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 20). ", ]) # --- # name: test_component_config_validation_error[basic_include] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/basic_include/integrations/iot_domain.yaml, line 5). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/basic_include/integrations/iot_domain.yaml, line 8). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8). ", "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/basic_include/configuration.yaml, line 4). ", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 4). ", ]) # --- # name: test_component_config_validation_error[include_dir_list] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/include_dir_list/iot_domain/iot_domain_2.yaml, line 2). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/include_dir_list/iot_domain/iot_domain_3.yaml, line 2). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 2). ", ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 2). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 2). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5). ", ]) # --- # name: test_component_config_validation_error[packages] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/packages/configuration.yaml, line 11). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/packages/configuration.yaml, line 16). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 11). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 16). ", "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?). ", ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml, line 6). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/packages_include_dir_named/integrations/iot_domain.yaml, line 9). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6). ", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9). ", "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?). ", ]) # --- +# name: test_package_merge_error[packages] + list([ + 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages/configuration.yaml:9). ', + 'Package pack_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages/configuration.yaml:13). ', + "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages/configuration.yaml:20). ", + ]) +# --- +# name: test_package_merge_error[packages_include_dir_named] + list([ + 'Package adr_0007_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml:2). ', + 'Package adr_0007_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml:2). ', + "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml:1). ", + ]) +# --- diff --git a/tests/test_config.py b/tests/test_config.py index 83ab670387c..e8e45413117 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -33,7 +33,7 @@ from homeassistant.core import ConfigSource, HomeAssistant, HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir import homeassistant.helpers.check_config as check_config from homeassistant.helpers.entity import Entity -from homeassistant.loader import async_get_integration +from homeassistant.loader import Integration, async_get_integration from homeassistant.util.unit_system import ( _CONF_UNIT_SYSTEM_US_CUSTOMARY, METRIC_SYSTEM, @@ -94,6 +94,66 @@ def teardown(): os.remove(SAFE_MODE_PATH) +IOT_DOMAIN_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + + +@pytest.fixture +async def mock_iot_domain_integration(hass: HomeAssistant) -> Integration: + """Mock an integration which provides an IoT domain.""" + comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) + + return mock_integration( + hass, + MockModule( + "iot_domain", + platform_schema_base=comp_platform_schema_base, + platform_schema=comp_platform_schema, + ), + ) + + +@pytest.fixture +async def mock_non_adr_0007_integration(hass) -> None: + """Mock a non-ADR-0007 compliant integration with iot_domain platform. + + The integration allows setting up iot_domain entities under the iot_domain's + configuration key + """ + + test_platform_schema = IOT_DOMAIN_PLATFORM_SCHEMA.extend({"option1": str}) + mock_platform( + hass, + "non_adr_0007.iot_domain", + MockPlatform(platform_schema=test_platform_schema), + ) + + +@pytest.fixture +async def mock_adr_0007_integrations(hass) -> list[Integration]: + """Mock ADR-0007 compliant integrations.""" + integrations = [] + for domain in ["adr_0007_1", "adr_0007_2", "adr_0007_3"]: + adr_0007_config_schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Required("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + integrations.append( + mock_integration( + hass, + MockModule(domain, config_schema=adr_0007_config_schema), + ) + ) + return integrations + + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" assert not os.path.isfile(YAML_PATH) @@ -1425,52 +1485,16 @@ async def test_component_config_validation_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_dir: str, + mock_iot_domain_integration: Integration, + mock_non_adr_0007_integration: None, + mock_adr_0007_integrations: list[Integration], snapshot: SnapshotAssertion, ) -> None: """Test schema error in component.""" - comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) - comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) - - # Mock an integration which provides an IoT domain - mock_integration( - hass, - MockModule( - "iot_domain", - platform_schema_base=comp_platform_schema_base, - platform_schema=comp_platform_schema, - ), - ) - - # Mock a non-ADR-0007 compliant integration which allows setting up - # iot_domain entities under the iot_domain's configuration key - test_platform_schema = comp_platform_schema.extend({"option1": str}) - mock_platform( - hass, - "non_adr_0007.iot_domain", - MockPlatform(platform_schema=test_platform_schema), - ) - - # Mock an ADR-0007 compliant integration - for domain in ["adr_0007_1", "adr_0007_2", "adr_0007_3"]: - adr_0007_config_schema = vol.Schema( - { - domain: vol.Schema( - { - vol.Required("host"): str, - vol.Required("port", default=8080): int, - } - ) - }, - extra=vol.ALLOW_EXTRA, - ) - mock_integration( - hass, - MockModule(domain, config_schema=adr_0007_config_schema), - ) base_path = os.path.dirname(__file__) hass.config.config_dir = os.path.join( - base_path, "fixtures", "core", "config", config_dir + base_path, "fixtures", "core", "config", "component_validation", config_dir ) config = await config_util.async_hass_config_yaml(hass) @@ -1488,3 +1512,31 @@ async def test_component_config_validation_error( if record.levelno == logging.ERROR ] assert error_records == snapshot + + +@pytest.mark.parametrize( + "config_dir", + ["packages", "packages_include_dir_named"], +) +async def test_package_merge_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + mock_iot_domain_integration: Integration, + mock_non_adr_0007_integration: None, + mock_adr_0007_integrations: list[Integration], + snapshot: SnapshotAssertion, +) -> None: + """Test schema error in component.""" + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "package_errors", config_dir + ) + await config_util.async_hass_config_yaml(hass) + + error_records = [ + record.message.replace(base_path, "") + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot From 92b3c0c96b37873a1ccae21459bb28a1c8b3ba60 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 13 Nov 2023 11:51:55 +0100 Subject: [PATCH 427/982] Update i-j* tests to use entity & device registry fixtures (#103900) --- tests/components/ibeacon/test_init.py | 5 +- tests/components/input_boolean/test_init.py | 43 ++++++++------- tests/components/input_button/test_init.py | 37 +++++++------ tests/components/input_datetime/test_init.py | 48 ++++++++++------- tests/components/input_number/test_init.py | 44 +++++++++------- tests/components/input_select/test_init.py | 52 +++++++++++-------- tests/components/input_text/test_init.py | 26 ++++++---- tests/components/insteon/test_api_device.py | 9 ++-- tests/components/insteon/test_lock.py | 16 +++--- tests/components/integration/test_init.py | 6 +-- tests/components/integration/test_sensor.py | 9 ++-- tests/components/intent/test_init.py | 6 +-- tests/components/ipp/test_sensor.py | 10 ++-- .../islamic_prayer_times/test_init.py | 11 ++-- .../components/jellyfin/test_media_player.py | 8 ++- tests/components/jellyfin/test_sensor.py | 5 +- .../jewish_calendar/test_binary_sensor.py | 5 +- .../components/jewish_calendar/test_sensor.py | 5 +- tests/components/jvc_projector/test_init.py | 2 +- tests/components/jvc_projector/test_remote.py | 3 +- 20 files changed, 199 insertions(+), 151 deletions(-) diff --git a/tests/components/ibeacon/test_init.py b/tests/components/ibeacon/test_init.py index 2e3aafb4984..b29cc3a4b2e 100644 --- a/tests/components/ibeacon/test_init.py +++ b/tests/components/ibeacon/test_init.py @@ -33,7 +33,9 @@ async def remove_device(ws_client, device_id, config_entry_id): async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a device that no longer exists.""" entry = MockConfigEntry( @@ -46,7 +48,6 @@ async def test_device_remove_devices( await hass.async_block_till_done() inject_bluetooth_service_info(hass, BLUECHARM_BEACON_SERVICE_INFO) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={ diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index 65451856002..4caf914ca19 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -195,10 +195,11 @@ async def test_input_boolean_context( assert state2.context.user_id == hass_admin_user.id -async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: +async def test_reload( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_admin_user: MockUser +) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) _LOGGER.debug("ENTITIES @ start: %s", hass.states.async_entity_ids()) @@ -226,9 +227,9 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: assert state_3 is None assert state_2.state == STATE_ON - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None with patch( "homeassistant.config.load_yaml_config_file", @@ -261,9 +262,9 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: assert state_2 is not None assert state_3 is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None assert state_2.state == STATE_ON # reload is not supposed to change entity state assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World reloaded" @@ -316,18 +317,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -339,11 +342,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_ws_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test update WS.""" @@ -355,12 +361,11 @@ async def test_ws_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None assert state.state - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -400,18 +405,20 @@ async def test_ws_update( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index f3b4eef36f5..9233668c113 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -133,10 +133,11 @@ async def test_input_button_context( assert state2.context.user_id == hass_admin_user.id -async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: +async def test_reload( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_admin_user: MockUser +) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) _LOGGER.debug("ENTITIES @ start: %s", hass.states.async_entity_ids()) @@ -163,9 +164,9 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: assert state_2 is not None assert state_3 is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None with patch( "homeassistant.config.load_yaml_config_file", @@ -197,9 +198,9 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: assert state_2 is not None assert state_3 is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None async def test_reload_not_changing_state(hass: HomeAssistant, storage_setup) -> None: @@ -288,7 +289,10 @@ async def test_ws_list( async def test_ws_create_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test creating and updating via WS.""" assert await storage_setup(config={DOMAIN: {}}) @@ -304,8 +308,7 @@ async def test_ws_create_update( assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_FRIENDLY_NAME) == "new" - ent_reg = er.async_get(hass) - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None await client.send_json( {"id": 8, "type": f"{DOMAIN}/update", f"{DOMAIN}_id": "new", "name": "newer"} @@ -319,22 +322,24 @@ async def test_ws_create_update( assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_FRIENDLY_NAME) == "newer" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -346,7 +351,7 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) -> None: diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 940d0ff6c55..a0b80ac420c 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -423,11 +423,13 @@ async def test_input_datetime_context( async def test_reload( - hass: HomeAssistant, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) assert await async_setup_component( hass, @@ -451,9 +453,9 @@ async def test_reload( assert state_2 is None assert state_3 is not None assert dt_obj.strftime(FORMAT_DATE) == state_1.state - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt2") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt3") == f"{DOMAIN}.dt3" + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt2") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt3") == f"{DOMAIN}.dt3" with patch( "homeassistant.config.load_yaml_config_file", @@ -493,9 +495,9 @@ async def test_reload( datetime.date.today(), DEFAULT_TIME ).strftime(FORMAT_DATETIME) - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt2") == f"{DOMAIN}.dt2" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt2") == f"{DOMAIN}.dt2" + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt3") is None async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: @@ -553,18 +555,22 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.datetime_from_storage" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) == input_entity_id + assert ( + entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) == input_entity_id + ) client = await hass_ws_client(hass) @@ -576,11 +582,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating min/max updates the state.""" @@ -588,12 +597,13 @@ async def test_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.datetime_from_storage" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes[ATTR_FRIENDLY_NAME] == "datetime from storage" assert state.state == INITIAL_DATETIME - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) == input_entity_id + assert ( + entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) == input_entity_id + ) client = await hass_ws_client(hass) @@ -621,18 +631,20 @@ async def test_update( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_datetime" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 3703ca39cd5..1334ba4aebd 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -343,11 +343,13 @@ async def test_input_number_context( async def test_reload( - hass: HomeAssistant, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) assert await async_setup_component( hass, @@ -371,9 +373,9 @@ async def test_reload( assert state_3 is not None assert float(state_1.state) == 50 assert float(state_3.state) == 10 - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None with patch( "homeassistant.config.load_yaml_config_file", @@ -411,9 +413,9 @@ async def test_reload( assert state_3 is None assert float(state_1.state) == 50 assert float(state_2.state) == 20 - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: @@ -486,18 +488,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -509,11 +513,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update_min_max( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating min/max updates the state.""" @@ -529,12 +536,11 @@ async def test_update_min_max( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None assert state.state - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -572,18 +578,20 @@ async def test_update_min_max( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 6908a1c532e..03c503ae494 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -447,11 +447,13 @@ async def test_input_select_context( async def test_reload( - hass: HomeAssistant, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) assert await async_setup_component( hass, @@ -481,9 +483,9 @@ async def test_reload( assert state_3 is None assert state_1.state == "middle option" assert state_2.state == "an option" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None with patch( "homeassistant.config.load_yaml_config_file", @@ -526,9 +528,9 @@ async def test_reload( assert state_3 is not None assert state_2.state == "an option" assert state_3.state == "newer option" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: @@ -611,18 +613,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -634,11 +638,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating options updates the state.""" @@ -651,11 +658,10 @@ async def test_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes[ATTR_OPTIONS] == ["yaml update 1", "yaml update 2"] - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -697,6 +703,7 @@ async def test_update( async def test_update_duplicates( hass: HomeAssistant, + entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, storage_setup, caplog: pytest.LogCaptureFixture, @@ -712,11 +719,10 @@ async def test_update_duplicates( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes[ATTR_OPTIONS] == ["yaml update 1", "yaml update 2"] - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -742,18 +748,20 @@ async def test_update_duplicates( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) @@ -776,6 +784,7 @@ async def test_ws_create( async def test_ws_create_duplicates( hass: HomeAssistant, + entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, storage_setup, caplog: pytest.LogCaptureFixture, @@ -785,11 +794,10 @@ async def test_ws_create_duplicates( input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index ea12eabd04f..23d1c3307e5 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -397,18 +397,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -420,11 +422,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating min/max updates the state.""" @@ -432,13 +437,12 @@ async def test_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes[ATTR_FRIENDLY_NAME] == "from storage" assert state.attributes[ATTR_MODE] == MODE_TEXT assert state.state == "loaded from storage" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -470,18 +474,20 @@ async def test_update( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py index ce061e47c3d..7485914026a 100644 --- a/tests/components/insteon/test_api_device.py +++ b/tests/components/insteon/test_api_device.py @@ -87,7 +87,9 @@ async def test_no_ha_device( async def test_no_insteon_device( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test response when no Insteon device exists.""" config_entry = MockConfigEntry( @@ -103,15 +105,14 @@ async def test_no_insteon_device( devices = MockDevices() await devices.async_load() - dev_reg = dr.async_get(hass) # Create device registry entry for a Insteon device not in the Insteon devices list - ha_device_1 = dev_reg.async_get_or_create( + ha_device_1 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "AA.BB.CC")}, name="HA Device Only", ) # Create device registry entry for a non-Insteon device - ha_device_2 = dev_reg.async_get_or_create( + ha_device_2 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("other_domain", "no address")}, name="HA Device Only", diff --git a/tests/components/insteon/test_lock.py b/tests/components/insteon/test_lock.py index 42a6d511b7e..f96e33af1c8 100644 --- a/tests/components/insteon/test_lock.py +++ b/tests/components/insteon/test_lock.py @@ -57,18 +57,20 @@ async def mock_connection(*args, **kwargs): return True -async def test_lock_lock(hass: HomeAssistant) -> None: +async def test_lock_lock( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test locking an Insteon lock device.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT_PLM) config_entry.add_to_hass(hass) - registry_entity = er.async_get(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() try: - lock = registry_entity.async_get("lock.device_55_55_55_55_55_55") + lock = entity_registry.async_get("lock.device_55_55_55_55_55_55") state = hass.states.get(lock.entity_id) assert state.state is STATE_UNLOCKED @@ -82,19 +84,21 @@ async def test_lock_lock(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_lock_unlock(hass: HomeAssistant) -> None: +async def test_lock_unlock( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test locking an Insteon lock device.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT_PLM) config_entry.add_to_hass(hass) - registry_entity = er.async_get(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() devices["55.55.55"].groups[1].set_value(255) try: - lock = registry_entity.async_get("lock.device_55_55_55_55_55_55") + lock = entity_registry.async_get("lock.device_55_55_55_55_55_55") state = hass.states.get(lock.entity_id) assert state.state is STATE_LOCKED diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index b68e3cdb1eb..885c10277f8 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -11,11 +11,11 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("platform", ("sensor",)) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, platform: str, ) -> None: """Test setting up and removing a config entry.""" input_sensor_entity_id = "sensor.input" - registry = er.async_get(hass) integration_entity_id = f"{platform}.my_integration" # Setup the config entry @@ -37,7 +37,7 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(integration_entity_id) is not None + assert entity_registry.async_get(integration_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(integration_entity_id) @@ -58,4 +58,4 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(integration_entity_id) is None - assert registry.async_get(integration_entity_id) is None + assert entity_registry.async_get(integration_entity_id) is None diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 0c2744dd654..8ef9caf4928 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -679,11 +679,12 @@ async def test_calc_errors(hass: HomeAssistant, method) -> None: assert round(float(state.state)) == 0 if method != "right" else 1 -async def test_device_id(hass: HomeAssistant) -> None: +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for source entity device for Riemann sum integral.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - source_config_entry = MockConfigEntry() source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 6e4e00202c8..d80add2a441 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -119,15 +119,15 @@ async def test_turn_on_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": ["light.test_light"]} -async def test_translated_turn_on_intent(hass: HomeAssistant) -> None: +async def test_translated_turn_on_intent( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test HassTurnOn intent on domains which don't have the intent.""" result = await async_setup_component(hass, "homeassistant", {}) result = await async_setup_component(hass, "intent", {}) await hass.async_block_till_done() assert result - entity_registry = er.async_get(hass) - cover = entity_registry.async_get_or_create("cover", "test", "cover_uid") lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index 5992b928f63..cbcad903898 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -79,15 +79,14 @@ async def test_sensors( async def test_disabled_by_default_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the disabled by default IPP sensors.""" - registry = er.async_get(hass) - state = hass.states.get("sensor.test_ha_1000_series_uptime") assert state is None - entry = registry.async_get("sensor.test_ha_1000_series_uptime") + entry = entity_registry.async_get("sensor.test_ha_1000_series_uptime") assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -95,6 +94,7 @@ async def test_disabled_by_default_sensors( async def test_missing_entry_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_ipp: AsyncMock, ) -> None: @@ -105,8 +105,6 @@ async def test_missing_entry_unique_id( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - - entity = registry.async_get("sensor.test_ha_1000_series") + entity = entity_registry.async_get("sensor.test_ha_1000_series") assert entity assert entity.unique_id == f"{mock_config_entry.entry_id}_printer" diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index a1fcf32efba..0a41630e29b 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -154,15 +154,16 @@ async def test_update_failed(hass: HomeAssistant) -> None: ], ) async def test_migrate_unique_id( - hass: HomeAssistant, object_id: str, old_unique_id: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + object_id: str, + old_unique_id: str, ) -> None: """Test unique id migration.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - - entity: er.RegistryEntry = ent_reg.async_get_or_create( + entity: er.RegistryEntry = entity_registry.async_get_or_create( suggested_object_id=object_id, domain=SENSOR_DOMAIN, platform=islamic_prayer_times.DOMAIN, @@ -178,6 +179,6 @@ async def test_migrate_unique_id( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity_migrated = ent_reg.async_get(entity.entity_id) + entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == f"{entry.entry_id}-{old_unique_id}" diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 64ed41ffdfa..00fe230b31f 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -41,14 +41,13 @@ from tests.typing import WebSocketGenerator async def test_media_player( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_jellyfin: MagicMock, mock_api: MagicMock, ) -> None: """Test the Jellyfin media player.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("media_player.jellyfin_device") assert state @@ -97,13 +96,12 @@ async def test_media_player( async def test_media_player_music( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_jellyfin: MagicMock, mock_api: MagicMock, ) -> None: """Test the Jellyfin media player.""" - entity_registry = er.async_get(hass) - state = hass.states.get("media_player.jellyfin_device_four") assert state diff --git a/tests/components/jellyfin/test_sensor.py b/tests/components/jellyfin/test_sensor.py index 087be30b70c..733cb795271 100644 --- a/tests/components/jellyfin/test_sensor.py +++ b/tests/components/jellyfin/test_sensor.py @@ -17,13 +17,12 @@ from tests.common import MockConfigEntry async def test_watching( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_jellyfin: MagicMock, ) -> None: """Test the Jellyfin watching sensor.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("sensor.jellyfin_server") assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 4b40519598f..d14ae0faad2 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -169,6 +169,7 @@ MELACHA_TEST_IDS = [ ) async def test_issur_melacha_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, now, candle_lighting, havdalah, @@ -186,8 +187,6 @@ async def test_issur_melacha_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - registry = er.async_get(hass) - with alter_time(test_time): assert await async_setup_component( hass, @@ -208,7 +207,7 @@ async def test_issur_melacha_sensor( hass.states.get("binary_sensor.test_issur_melacha_in_effect").state == result["state"] ) - entity = registry.async_get("binary_sensor.test_issur_melacha_in_effect") + entity = entity_registry.async_get("binary_sensor.test_issur_melacha_in_effect") target_uid = "_".join( map( str, diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 1aa7fad00d2..0f2912e9de3 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -496,6 +496,7 @@ SHABBAT_TEST_IDS = [ ) async def test_shabbat_times_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, language, now, candle_lighting, @@ -514,8 +515,6 @@ async def test_shabbat_times_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - registry = er.async_get(hass) - with alter_time(test_time): assert await async_setup_component( hass, @@ -552,7 +551,7 @@ async def test_shabbat_times_sensor( result_value ), f"Value for {sensor_type}" - entity = registry.async_get(f"sensor.test_{sensor_type}") + entity = entity_registry.async_get(f"sensor.test_{sensor_type}") target_sensor_type = sensor_type.replace("parshat_hashavua", "weekly_portion") target_uid = "_".join( map( diff --git a/tests/components/jvc_projector/test_init.py b/tests/components/jvc_projector/test_init.py index 0f1ef8b6dcf..ef9de41ca32 100644 --- a/tests/components/jvc_projector/test_init.py +++ b/tests/components/jvc_projector/test_init.py @@ -16,11 +16,11 @@ from tests.common import MockConfigEntry async def test_init( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_device: AsyncMock, mock_integration: MockConfigEntry, ) -> None: """Test initialization.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_MAC)}) assert device is not None assert device.identifiers == {(DOMAIN, MOCK_MAC)} diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py index 5beccd33e38..5505e160ca7 100644 --- a/tests/components/jvc_projector/test_remote.py +++ b/tests/components/jvc_projector/test_remote.py @@ -21,13 +21,14 @@ ENTITY_ID = "remote.jvc_projector" async def test_entity_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_device: MagicMock, mock_integration: MockConfigEntry, ) -> None: """Tests entity state is registered.""" entity = hass.states.get(ENTITY_ID) assert entity - assert er.async_get(hass).async_get(entity.entity_id) + assert entity_registry.async_get(entity.entity_id) async def test_commands( From d4c5a93b638356686d003321919a1639a4c6f4e6 Mon Sep 17 00:00:00 2001 From: FredericMa Date: Mon, 13 Nov 2023 12:24:20 +0100 Subject: [PATCH 428/982] Add Risco communication delay (#101349) * Add optional communication delay in case a panel responds slow. * Add migration test * Connect by increasing the communication delay * Update init to use option instead of config * Updated strings * Fix migration and tests * Review changes * Update connect strategy * Update tests * Changes after review * Change typing from object to Any. * Add test to validate communication delay update. * Move test to separate file * Change filename. --- homeassistant/components/risco/__init__.py | 34 ++++++++++++++----- homeassistant/components/risco/config_flow.py | 32 ++++++++++++++--- homeassistant/components/risco/const.py | 3 ++ homeassistant/components/risco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/risco/conftest.py | 10 ++++++ tests/components/risco/test_config_flow.py | 7 ++-- tests/components/risco/test_init.py | 21 ++++++++++++ 9 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 tests/components/risco/test_init.py diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 88f8ba9bdfa..9c62447ee04 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -35,10 +35,12 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + CONF_COMMUNICATION_DELAY, DATA_COORDINATOR, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENTS_COORDINATOR, + MAX_COMMUNICATION_DELAY, TYPE_LOCAL, ) @@ -81,15 +83,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = entry.data - risco = RiscoLocal(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + comm_delay = initial_delay = data.get(CONF_COMMUNICATION_DELAY, 0) - try: - await risco.connect() - except CannotConnectError as error: - raise ConfigEntryNotReady() from error - except UnauthorizedError: - _LOGGER.exception("Failed to login to Risco cloud") - return False + while True: + risco = RiscoLocal( + data[CONF_HOST], + data[CONF_PORT], + data[CONF_PIN], + communication_delay=comm_delay, + ) + try: + await risco.connect() + except CannotConnectError as error: + if comm_delay >= MAX_COMMUNICATION_DELAY: + raise ConfigEntryNotReady() from error + comm_delay += 1 + except UnauthorizedError: + _LOGGER.exception("Failed to login to Risco cloud") + return False + else: + break + + if comm_delay > initial_delay: + new_data = data.copy() + new_data[CONF_COMMUNICATION_DELAY] = comm_delay + hass.config_entries.async_update_entry(entry, data=new_data) async def _error(error: Exception) -> None: _LOGGER.error("Error in Risco library: %s", error) diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index 0f532a376a1..ef96714742d 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -28,10 +28,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_CODE_ARM_REQUIRED, CONF_CODE_DISARM_REQUIRED, + CONF_COMMUNICATION_DELAY, CONF_HA_STATES_TO_RISCO, CONF_RISCO_STATES_TO_HA, DEFAULT_OPTIONS, DOMAIN, + MAX_COMMUNICATION_DELAY, RISCO_STATES, TYPE_LOCAL, ) @@ -78,16 +80,31 @@ async def validate_cloud_input(hass: core.HomeAssistant, data) -> dict[str, str] async def validate_local_input( hass: core.HomeAssistant, data: Mapping[str, str] -) -> dict[str, str]: +) -> dict[str, Any]: """Validate the user input allows us to connect to a local panel. Data has the keys from LOCAL_SCHEMA with values provided by the user. """ - risco = RiscoLocal(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) - await risco.connect() + comm_delay = 0 + while True: + risco = RiscoLocal( + data[CONF_HOST], + data[CONF_PORT], + data[CONF_PIN], + communication_delay=comm_delay, + ) + try: + await risco.connect() + except CannotConnectError as e: + if comm_delay >= MAX_COMMUNICATION_DELAY: + raise e + comm_delay += 1 + else: + break + site_id = risco.id await risco.disconnect() - return {"title": site_id} + return {"title": site_id, "comm_delay": comm_delay} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -170,7 +187,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( - title=info["title"], data={**user_input, **{CONF_TYPE: TYPE_LOCAL}} + title=info["title"], + data={ + **user_input, + **{CONF_TYPE: TYPE_LOCAL}, + **{CONF_COMMUNICATION_DELAY: info["comm_delay"]}, + }, ) return self.async_show_form( diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py index 9f0e71701c6..800003d2384 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -17,10 +17,13 @@ DEFAULT_SCAN_INTERVAL = 30 TYPE_LOCAL = "local" +MAX_COMMUNICATION_DELAY = 3 + CONF_CODE_ARM_REQUIRED = "code_arm_required" CONF_CODE_DISARM_REQUIRED = "code_disarm_required" CONF_RISCO_STATES_TO_HA = "risco_states_to_ha" CONF_HA_STATES_TO_RISCO = "ha_states_to_risco" +CONF_COMMUNICATION_DELAY = "communication_delay" RISCO_GROUPS = ["A", "B", "C", "D"] RISCO_ARM = "arm" diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 5b208d1fc18..ca28af3d8e5 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyrisco"], "quality_scale": "platinum", - "requirements": ["pyrisco==0.5.7"] + "requirements": ["pyrisco==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3cb6eaff697..3a2758e3a9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2001,7 +2001,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.5.7 +pyrisco==0.5.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ece8f95a86..aa187d8c886 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1508,7 +1508,7 @@ pyqwikswitch==0.93 pyrainbird==4.0.0 # homeassistant.components.risco -pyrisco==0.5.7 +pyrisco==0.5.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 diff --git a/tests/components/risco/conftest.py b/tests/components/risco/conftest.py index cb3b3dd929e..325e787bb4f 100644 --- a/tests/components/risco/conftest.py +++ b/tests/components/risco/conftest.py @@ -171,6 +171,16 @@ def connect_with_error(exception): yield +@pytest.fixture +def connect_with_single_error(exception): + """Fixture to simulate error on connect.""" + with patch( + "homeassistant.components.risco.RiscoLocal.connect", + side_effect=[exception, None], + ): + yield + + @pytest.fixture async def setup_risco_local(hass, local_config_entry): """Set up a local Risco integration for testing.""" diff --git a/tests/components/risco/test_config_flow.py b/tests/components/risco/test_config_flow.py index 5a9b60ed130..fdb51c65dda 100644 --- a/tests/components/risco/test_config_flow.py +++ b/tests/components/risco/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.risco.config_flow import ( CannotConnectError, UnauthorizedError, ) -from homeassistant.components.risco.const import DOMAIN +from homeassistant.components.risco.const import CONF_COMMUNICATION_DELAY, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -246,7 +246,10 @@ async def test_local_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - expected_data = {**TEST_LOCAL_DATA, **{"type": "local"}} + expected_data = { + **TEST_LOCAL_DATA, + **{"type": "local", CONF_COMMUNICATION_DELAY: 0}, + } assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == TEST_SITE_NAME assert result3["data"] == expected_data diff --git a/tests/components/risco/test_init.py b/tests/components/risco/test_init.py new file mode 100644 index 00000000000..a1a9e3bd6a7 --- /dev/null +++ b/tests/components/risco/test_init.py @@ -0,0 +1,21 @@ +"""Tests for the Risco initialization.""" +import pytest + +from homeassistant.components.risco import CannotConnectError +from homeassistant.components.risco.const import CONF_COMMUNICATION_DELAY +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize("exception", [CannotConnectError]) +async def test_single_error_on_connect( + hass: HomeAssistant, connect_with_single_error, local_config_entry +) -> None: + """Test single error on connect to validate communication delay update from 0 (default) to 1.""" + expected_data = { + **local_config_entry.data, + **{"type": "local", CONF_COMMUNICATION_DELAY: 1}, + } + + await hass.config_entries.async_setup(local_config_entry.entry_id) + await hass.async_block_till_done() + assert local_config_entry.data == expected_data From 8e71086c2177f988282c35b2009f3e7902538d60 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Nov 2023 13:41:46 +0100 Subject: [PATCH 429/982] Workday add languages (#103127) --- homeassistant/components/workday/__init__.py | 10 ++- .../components/workday/binary_sensor.py | 6 +- .../components/workday/config_flow.py | 53 +++++++---- homeassistant/components/workday/strings.json | 12 ++- tests/components/workday/__init__.py | 30 +++++++ .../components/workday/test_binary_sensor.py | 2 + tests/components/workday/test_config_flow.py | 90 ++++++++++++++++++- 7 files changed, 177 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 558e0aa9ecf..455f5d4618a 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -1,9 +1,10 @@ """Sensor to indicate whether the current day is a workday.""" from __future__ import annotations -from holidays import list_supported_countries +from holidays import HolidayBase, country_holidays, list_supported_countries from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LANGUAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -17,6 +18,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: country: str | None = entry.options.get(CONF_COUNTRY) province: str | None = entry.options.get(CONF_PROVINCE) + if country and CONF_LANGUAGE not in entry.options: + cls: HolidayBase = country_holidays(country, subdiv=province) + default_language = cls.default_language + new_options = entry.options.copy() + new_options[CONF_LANGUAGE] = default_language + hass.config_entries.async_update_entry(entry, options=new_options) + if country and country not in list_supported_countries(): async_create_issue( hass, diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 2692c27d58a..26f44fa1e2d 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -72,16 +72,16 @@ async def async_setup_entry( province: str | None = entry.options.get(CONF_PROVINCE) sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] + language: str | None = entry.options.get(CONF_LANGUAGE) year: int = (dt_util.now() + timedelta(days=days_offset)).year if country: - cls: HolidayBase = country_holidays(country, subdiv=province, years=year) obj_holidays: HolidayBase = country_holidays( country, subdiv=province, years=year, - language=cls.default_language, + language=language, ) else: obj_holidays = HolidayBase() diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index c4b1f1ba3fd..1fbeea0684d 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigFlow, OptionsFlowWithConfigEntry, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError @@ -46,7 +46,7 @@ from .const import ( ) -def add_province_to_schema( +def add_province_and_language_to_schema( schema: vol.Schema, country: str | None, ) -> vol.Schema: @@ -55,20 +55,36 @@ def add_province_to_schema( return schema all_countries = list_supported_countries(include_aliases=False) - if not all_countries.get(country): - return schema - add_schema = { - vol.Optional(CONF_PROVINCE): SelectSelector( - SelectSelectorConfig( - options=all_countries[country], - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_PROVINCE, + language_schema = {} + province_schema = {} + + _country = country_holidays(country=country) + if country_default_language := (_country.default_language): + selectable_languages = _country.supported_languages + language_schema = { + vol.Optional( + CONF_LANGUAGE, default=country_default_language + ): SelectSelector( + SelectSelectorConfig( + options=list(selectable_languages), + mode=SelectSelectorMode.DROPDOWN, + ) ) - ), - } + } - return vol.Schema({**DATA_SCHEMA_OPT.schema, **add_schema}) + if provinces := all_countries.get(country): + province_schema = { + vol.Optional(CONF_PROVINCE): SelectSelector( + SelectSelectorConfig( + options=provinces, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PROVINCE, + ) + ), + } + + return vol.Schema({**DATA_SCHEMA_OPT.schema, **language_schema, **province_schema}) def _is_valid_date_range(check_date: str, error: type[HomeAssistantError]) -> bool: @@ -93,12 +109,11 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: year: int = dt_util.now().year if country := user_input.get(CONF_COUNTRY): - cls = country_holidays(country) obj_holidays = country_holidays( country=country, subdiv=user_input.get(CONF_PROVINCE), years=year, - language=cls.default_language, + language=user_input.get(CONF_LANGUAGE), ) else: obj_holidays = HolidayBase(years=year) @@ -237,7 +252,9 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.data.get(CONF_COUNTRY) + add_province_and_language_to_schema, + DATA_SCHEMA_OPT, + self.data.get(CONF_COUNTRY), ) new_schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form( @@ -298,7 +315,9 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): return self.async_create_entry(data=combined_input) schema: vol.Schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.options.get(CONF_COUNTRY) + add_province_and_language_to_schema, + DATA_SCHEMA_OPT, + self.options.get(CONF_COUNTRY), ) new_schema = self.add_suggested_values_to_schema( diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index a05ab1fc669..20e7cd26fd6 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -19,7 +19,8 @@ "workdays": "Workdays", "add_holidays": "Add holidays", "remove_holidays": "Remove Holidays", - "province": "Subdivision of country" + "province": "Subdivision of country", + "language": "Language for named holidays" }, "data_description": { "excludes": "List of workdays to exclude", @@ -27,7 +28,8 @@ "workdays": "List of workdays", "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", - "province": "State, Territory, Province, Region of Country" + "province": "State, Territory, Province, Region of Country", + "language": "Choose the language you want to configure named holidays after" } } }, @@ -48,7 +50,8 @@ "workdays": "[%key:component::workday::config::step::options::data::workdays%]", "add_holidays": "[%key:component::workday::config::step::options::data::add_holidays%]", "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]", - "province": "[%key:component::workday::config::step::options::data::province%]" + "province": "[%key:component::workday::config::step::options::data::province%]", + "language": "[%key:component::workday::config::step::options::data::language%]" }, "data_description": { "excludes": "[%key:component::workday::config::step::options::data_description::excludes%]", @@ -56,7 +59,8 @@ "workdays": "[%key:component::workday::config::step::options::data_description::workdays%]", "add_holidays": "[%key:component::workday::config::step::options::data_description::add_holidays%]", "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]", - "province": "[%key:component::workday::config::step::options::data_description::province%]" + "province": "[%key:component::workday::config::step::options::data_description::province%]", + "language": "[%key:component::workday::config::step::options::data_description::language%]" } } }, diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index f9e44359b00..f2744758efb 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -65,6 +65,17 @@ TEST_CONFIG_WITH_PROVINCE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", +} +TEST_CONFIG_NO_LANGUAGE_CONFIGURED = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], } TEST_CONFIG_INCORRECT_COUNTRY = { "name": DEFAULT_NAME, @@ -74,6 +85,7 @@ TEST_CONFIG_INCORRECT_COUNTRY = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_PROVINCE = { "name": DEFAULT_NAME, @@ -84,6 +96,7 @@ TEST_CONFIG_INCORRECT_PROVINCE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_NO_PROVINCE = { "name": DEFAULT_NAME, @@ -93,6 +106,7 @@ TEST_CONFIG_NO_PROVINCE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_WITH_STATE = { "name": DEFAULT_NAME, @@ -103,6 +117,7 @@ TEST_CONFIG_WITH_STATE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_NO_STATE = { "name": DEFAULT_NAME, @@ -112,6 +127,7 @@ TEST_CONFIG_NO_STATE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_INCLUDE_HOLIDAY = { "name": DEFAULT_NAME, @@ -122,6 +138,7 @@ TEST_CONFIG_INCLUDE_HOLIDAY = { "workdays": ["holiday"], "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_EXAMPLE_1 = { "name": DEFAULT_NAME, @@ -131,6 +148,7 @@ TEST_CONFIG_EXAMPLE_1 = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_EXAMPLE_2 = { "name": DEFAULT_NAME, @@ -141,6 +159,7 @@ TEST_CONFIG_EXAMPLE_2 = { "workdays": ["mon", "wed", "fri"], "add_holidays": ["2020-02-24"], "remove_holidays": [], + "language": "de", } TEST_CONFIG_REMOVE_HOLIDAY = { "name": DEFAULT_NAME, @@ -150,6 +169,7 @@ TEST_CONFIG_REMOVE_HOLIDAY = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": ["2020-12-25", "2020-11-26"], + "language": "en_US", } TEST_CONFIG_REMOVE_NAMED = { "name": DEFAULT_NAME, @@ -159,6 +179,7 @@ TEST_CONFIG_REMOVE_NAMED = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], + "language": "en_US", } TEST_CONFIG_TOMORROW = { "name": DEFAULT_NAME, @@ -168,6 +189,7 @@ TEST_CONFIG_TOMORROW = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_DAY_AFTER_TOMORROW = { "name": DEFAULT_NAME, @@ -177,6 +199,7 @@ TEST_CONFIG_DAY_AFTER_TOMORROW = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_YESTERDAY = { "name": DEFAULT_NAME, @@ -186,6 +209,7 @@ TEST_CONFIG_YESTERDAY = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_ADD_REMOVE = { "name": DEFAULT_NAME, @@ -196,6 +220,7 @@ TEST_CONFIG_INCORRECT_ADD_REMOVE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": ["2023-12-32"], "remove_holidays": ["2023-12-32"], + "language": "de", } TEST_CONFIG_INCORRECT_ADD_DATE_RANGE = { "name": DEFAULT_NAME, @@ -206,6 +231,7 @@ TEST_CONFIG_INCORRECT_ADD_DATE_RANGE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": ["2023-12-01", "2023-12-30,2023-12-32"], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE = { "name": DEFAULT_NAME, @@ -216,6 +242,7 @@ TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": ["2023-12-25", "2023-12-30,2023-12-32"], + "language": "de", } TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN = { "name": DEFAULT_NAME, @@ -226,6 +253,7 @@ TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN = { "workdays": DEFAULT_WORKDAYS, "add_holidays": ["2023-12-01", "2023-12-29,2023-12-30,2023-12-31"], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN = { "name": DEFAULT_NAME, @@ -236,6 +264,7 @@ TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": ["2023-12-25", "2023-12-29,2023-12-30,2023-12-31"], + "language": "de", } TEST_CONFIG_ADD_REMOVE_DATE_RANGE = { "name": DEFAULT_NAME, @@ -246,4 +275,5 @@ TEST_CONFIG_ADD_REMOVE_DATE_RANGE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], + "language": "de", } diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index e955bd0de0d..6ce5b08ef27 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -26,6 +26,7 @@ from . import ( TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN, TEST_CONFIG_NO_COUNTRY, TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY, + TEST_CONFIG_NO_LANGUAGE_CONFIGURED, TEST_CONFIG_NO_PROVINCE, TEST_CONFIG_NO_STATE, TEST_CONFIG_REMOVE_HOLIDAY, @@ -51,6 +52,7 @@ from . import ( (TEST_CONFIG_TOMORROW, "off"), (TEST_CONFIG_DAY_AFTER_TOMORROW, "off"), (TEST_CONFIG_YESTERDAY, "on"), + (TEST_CONFIG_NO_LANGUAGE_CONFIGURED, "off"), ], ) async def test_setup( diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 89a001e0b55..3ecd518ce98 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -1,6 +1,9 @@ """Test the Workday config flow.""" from __future__ import annotations +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config_entries @@ -16,9 +19,10 @@ from homeassistant.components.workday.const import ( DEFAULT_WORKDAYS, DOMAIN, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.util.dt import UTC from . import init_integration @@ -49,6 +53,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -63,6 +68,7 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", } @@ -143,6 +149,7 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "sv", } @@ -159,6 +166,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", }, ) @@ -173,6 +181,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "add_holidays": [], "remove_holidays": [], "province": "BW", + "language": "de", }, ) @@ -186,6 +195,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "add_holidays": [], "remove_holidays": [], "province": "BW", + "language": "de", } @@ -213,6 +223,7 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-xx-12"], CONF_REMOVE_HOLIDAYS: [], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -226,6 +237,7 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["Does not exist"], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -240,6 +252,7 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["Weihnachtstag"], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -254,6 +267,7 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2022-12-12"], "remove_holidays": ["Weihnachtstag"], + "language": "de", } @@ -270,6 +284,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", }, ) @@ -284,6 +299,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-xx-12"], "remove_holidays": [], "province": "BW", + "language": "de", }, ) @@ -298,6 +314,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-12"], "remove_holidays": ["Does not exist"], "province": "BW", + "language": "de", }, ) @@ -312,6 +329,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-12"], "remove_holidays": ["Weihnachtstag"], "province": "BW", + "language": "de", }, ) @@ -325,6 +343,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-12"], "remove_holidays": ["Weihnachtstag"], "province": "BW", + "language": "de", } @@ -401,6 +420,7 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-30,2022-12-32"], CONF_REMOVE_HOLIDAYS: [], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -414,6 +434,7 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-32"], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -428,6 +449,7 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-01,2022-12-10"], CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-31"], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -442,6 +464,7 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2022-12-12", "2022-12-01,2022-12-10"], "remove_holidays": ["2022-12-25", "2022-12-30,2022-12-31"], + "language": "de", } @@ -458,6 +481,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", }, ) @@ -472,6 +496,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-30,2022-12-32"], "remove_holidays": [], "province": "BW", + "language": "de", }, ) @@ -486,6 +511,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-30,2022-12-31"], "remove_holidays": ["2022-13-25,2022-12-26"], "province": "BW", + "language": "de", }, ) @@ -500,6 +526,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-30,2022-12-31"], "remove_holidays": ["2022-12-25,2022-12-26"], "province": "BW", + "language": "de", }, ) @@ -513,4 +540,65 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-30,2022-12-31"], "remove_holidays": ["2022-12-25,2022-12-26"], "province": "BW", + "language": "de", } + + +pytestmark = pytest.mark.usefixtures() + + +@pytest.mark.parametrize( + ("language", "holiday"), + [ + ("de", "Weihnachtstag"), + ("en_US", "Christmas"), + ], +) +async def test_language( + hass: HomeAssistant, language: str, holiday: str, freezer: FrozenDateTimeFactory +) -> None: + """Test we get the forms.""" + freezer.move_to(datetime(2023, 12, 25, 12, tzinfo=UTC)) # Monday + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [holiday], + CONF_LANGUAGE: language, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [holiday], + "language": language, + } + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "on" From 9bd73ab362dc62bc8a888fb1d29dcc175b263d3f Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Nov 2023 13:53:49 +0100 Subject: [PATCH 430/982] Bump velbusaio to 2023.11.0 (#103798) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 3c773e39e33..1f0dd001853 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.2"], + "requirements": ["velbus-aio==2023.11.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 3a2758e3a9a..7400dbf8000 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2676,7 +2676,7 @@ vallox-websocket-api==4.0.2 vehicle==2.2.0 # homeassistant.components.velbus -velbus-aio==2023.10.2 +velbus-aio==2023.11.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa187d8c886..6321e1e3c5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1991,7 +1991,7 @@ vallox-websocket-api==4.0.2 vehicle==2.2.0 # homeassistant.components.velbus -velbus-aio==2023.10.2 +velbus-aio==2023.11.0 # homeassistant.components.venstar venstarcolortouch==0.19 From 1e375352bb1e28a7295165615e096b15387bffdc Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Mon, 13 Nov 2023 13:55:31 +0100 Subject: [PATCH 431/982] Use decorator for AsusWrt api calls (#103690) --- homeassistant/components/asuswrt/bridge.py | 79 +++++++++++++--------- tests/components/asuswrt/conftest.py | 2 +- tests/components/asuswrt/test_sensor.py | 25 +++++++ 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 9e6da0ea8f7..bbde9271984 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -3,8 +3,10 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections import namedtuple +from collections.abc import Awaitable, Callable, Coroutine +import functools import logging -from typing import Any, cast +from typing import Any, TypeVar, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy @@ -47,9 +49,38 @@ WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) _LOGGER = logging.getLogger(__name__) -def _get_dict(keys: list, values: list) -> dict[str, Any]: - """Create a dict from a list of keys and values.""" - return dict(zip(keys, values)) +_AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") +_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]] +_ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] + + +def handle_errors_and_zip( + exceptions: type[Exception] | tuple[type[Exception], ...], keys: list[str] | None +) -> Callable[[_FuncType], _ReturnFuncType]: + """Run library methods and zip results or manage exceptions.""" + + def _handle_errors_and_zip(func: _FuncType) -> _ReturnFuncType: + """Run library methods and zip results or manage exceptions.""" + + @functools.wraps(func) + async def _wrapper(self: _AsusWrtBridgeT) -> dict[str, Any]: + try: + data = await func(self) + except exceptions as exc: + raise UpdateFailed(exc) from exc + + if keys is None: + if not isinstance(data, dict): + raise UpdateFailed("Received invalid data type") + return data + + if not isinstance(data, list): + raise UpdateFailed("Received invalid data type") + return dict(zip(keys, data)) + + return _wrapper + + return _handle_errors_and_zip class AsusWrtBridge(ABC): @@ -236,38 +267,22 @@ class AsusWrtLegacyBridge(AsusWrtBridge): availability = await self._api.async_find_temperature_commands() return [SENSORS_TEMPERATURES[i] for i in range(3) if availability[i]] - async def _get_bytes(self) -> dict[str, Any]: + @handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_BYTES) + async def _get_bytes(self) -> Any: """Fetch byte information from the router.""" - try: - datas = await self._api.async_get_bytes_total() - except (IndexError, OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc + return await self._api.async_get_bytes_total() - return _get_dict(SENSORS_BYTES, datas) - - async def _get_rates(self) -> dict[str, Any]: + @handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_RATES) + async def _get_rates(self) -> Any: """Fetch rates information from the router.""" - try: - rates = await self._api.async_get_current_transfer_rates() - except (IndexError, OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc + return await self._api.async_get_current_transfer_rates() - return _get_dict(SENSORS_RATES, rates) - - async def _get_load_avg(self) -> dict[str, Any]: + @handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_LOAD_AVG) + async def _get_load_avg(self) -> Any: """Fetch load average information from the router.""" - try: - avg = await self._api.async_get_loadavg() - except (IndexError, OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc + return await self._api.async_get_loadavg() - return _get_dict(SENSORS_LOAD_AVG, avg) - - async def _get_temperatures(self) -> dict[str, Any]: + @handle_errors_and_zip((OSError, ValueError), None) + async def _get_temperatures(self) -> Any: """Fetch temperatures information from the router.""" - try: - temperatures: dict[str, Any] = await self._api.async_get_temperature() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return temperatures + return await self._api.async_get_temperature() diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 7596e94549d..ab574cd667f 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -13,7 +13,7 @@ ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" MOCK_BYTES_TOTAL = [60000000000, 50000000000] MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] MOCK_LOAD_AVG = [1.1, 1.2, 1.3] -MOCK_TEMPERATURES = {"2.4GHz": 40.2, "CPU": 71.2} +MOCK_TEMPERATURES = {"2.4GHz": 40.2, "5.0GHz": 0, "CPU": 71.2} @pytest.fixture(name="patch_setup_entry") diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index f0e21124fe3..b2fa13101bc 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -302,3 +302,28 @@ async def test_unique_id_migration( migr_entity = entity_registry.async_get(f"{sensor.DOMAIN}.{obj_entity_id}") assert migr_entity is not None assert migr_entity.unique_id == slugify(f"{ROUTER_MAC_ADDR}_sensor_tx_bytes") + + +async def test_decorator_errors( + hass: HomeAssistant, connect_legacy, mock_available_temps +) -> None: + """Test AsusWRT sensors are unavailable on decorator type check error.""" + sensors = [*SENSORS_BYTES, *SENSORS_TEMPERATURES] + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_TELNET, sensors) + config_entry.add_to_hass(hass) + + mock_available_temps[1] = True + connect_legacy.return_value.async_get_bytes_total.return_value = "bad_response" + connect_legacy.return_value.async_get_temperature.return_value = "bad_response" + + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + for sensor_name in sensors: + assert ( + hass.states.get(f"{sensor_prefix}_{slugify(sensor_name)}").state + == STATE_UNAVAILABLE + ) From 109bcd86f1f1fe3508e260379ee7dceb92cd0547 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 13 Nov 2023 13:57:29 +0100 Subject: [PATCH 432/982] Add reauth flow to ViCare integration (#103109) Co-authored-by: Robert Resch --- homeassistant/components/vicare/__init__.py | 10 ++- .../components/vicare/config_flow.py | 66 ++++++++++++++++--- homeassistant/components/vicare/strings.json | 8 +++ tests/components/vicare/test_config_flow.py | 57 +++++++++++++++- 4 files changed, 130 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 7a297ca8113..76de3a8a7ac 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -10,10 +10,15 @@ from typing import Any from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.storage import STORAGE_DIR from .const import ( @@ -53,7 +58,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN] = {} hass.data[DOMAIN][entry.entry_id] = {} - await hass.async_add_executor_job(setup_vicare_api, hass, entry) + try: + await hass.async_add_executor_job(setup_vicare_api, hass, entry) + except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError) as err: + raise ConfigEntryAuthFailed("Authentication failed") from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 5b2d3afa427..87bfcf7b146 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -1,6 +1,7 @@ """Config flow for ViCare integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -28,11 +29,28 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + } +) + +USER_SCHEMA = REAUTH_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value): vol.In( + [e.value for e in HeatingType] + ), + } +) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for ViCare.""" VERSION = 1 + entry: config_entries.ConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -41,14 +59,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - data_schema = { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value): vol.In( - [e.value for e in HeatingType] - ), - } errors: dict[str, str] = {} if user_input is not None: @@ -63,7 +73,45 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema(data_schema), + data_schema=USER_SCHEMA, + errors=errors, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with ViCare.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm re-authentication with ViCare.""" + errors: dict[str, str] = {} + assert self.entry is not None + + if user_input: + data = { + **self.entry.data, + **user_input, + } + + try: + await self.hass.async_add_executor_job(vicare_login, self.hass, data) + except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError): + errors["base"] = "invalid_auth" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data=data, + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + REAUTH_SCHEMA, self.entry.data + ), errors=errors, ) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 056a4df7920..2dc1eecd1e4 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -10,6 +10,13 @@ "client_id": "Client ID", "heating_type": "Heating type" } + }, + "reauth_confirm": { + "description": "Please verify credentials.", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "client_id": "[%key:component::vicare::config::step::user::data::client_id%]" + } } }, "error": { @@ -17,6 +24,7 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 7f70c13f0b0..283f06b754d 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -10,7 +10,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -93,6 +93,61 @@ async def test_user_create_entry( mock_setup_entry.assert_called_once() +async def test_step_reauth(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test reauth flow.""" + new_password = "ABCD" + new_client_id = "EFGH" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=VALID_CONFIG, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # test PyViCareInvalidConfigurationError + with patch( + f"{MODULE}.config_flow.vicare_login", + side_effect=PyViCareInvalidConfigurationError( + {"error": "foo", "error_description": "bar"} + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: new_password, CONF_CLIENT_ID: new_client_id}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # test success + with patch( + f"{MODULE}.config_flow.vicare_login", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: new_password, CONF_CLIENT_ID: new_client_id}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert ( + hass.config_entries.async_entries()[0].data[CONF_PASSWORD] == new_password + ) + assert ( + hass.config_entries.async_entries()[0].data[CONF_CLIENT_ID] == new_client_id + ) + await hass.async_block_till_done() + + async def test_form_dhcp( hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion ) -> None: From 2bdd969cf6508af41c76580baac1c5e51a3c0593 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 13 Nov 2023 14:04:12 +0100 Subject: [PATCH 433/982] fix Comelit cover stop (#103911) --- homeassistant/components/comelit/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 4a3c8eed63c..72bbf56e08a 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -109,7 +109,7 @@ class ComelitCoverEntity( if not self.is_closing and not self.is_opening: return - action = STATE_OFF if self.is_closing else STATE_ON + action = STATE_ON if self.is_closing else STATE_OFF await self._api.set_device_status(COVER, self._device.index, action) @callback From e64582ae9a237eb14099a00ce250221e40e773fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Nov 2023 14:04:58 +0100 Subject: [PATCH 434/982] Add tests for yaml syntax errors (#103908) --- .prettierignore | 1 + .yamllint | 1 + .../yaml_errors/basic/configuration.yaml | 4 ++ .../basic_include/configuration.yaml | 1 + .../integrations/iot_domain.yaml | 3 + .../include_dir_list/configuration.yaml | 1 + .../iot_domain/iot_domain_1.yaml | 3 + .../include_dir_merge_list/configuration.yaml | 1 + .../iot_domain/iot_domain_1.yaml | 3 + .../configuration.yaml | 3 + .../integrations/adr_0007_1.yaml | 4 ++ tests/snapshots/test_config.ambr | 70 +++++++++++++++++++ tests/test_config.py | 37 ++++++++++ 13 files changed, 132 insertions(+) create mode 100644 tests/fixtures/core/config/yaml_errors/basic/configuration.yaml create mode 100644 tests/fixtures/core/config/yaml_errors/basic_include/configuration.yaml create mode 100644 tests/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml create mode 100644 tests/fixtures/core/config/yaml_errors/include_dir_list/configuration.yaml create mode 100644 tests/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml create mode 100644 tests/fixtures/core/config/yaml_errors/include_dir_merge_list/configuration.yaml create mode 100644 tests/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml create mode 100644 tests/fixtures/core/config/yaml_errors/packages_include_dir_named/configuration.yaml create mode 100644 tests/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml diff --git a/.prettierignore b/.prettierignore index 07637a380c5..b249b537137 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ homeassistant/components/*/translations/*.json homeassistant/generated/* tests/components/lidarr/fixtures/initialize.js tests/components/lidarr/fixtures/initialize-wrong.js +tests/fixtures/core/config/yaml_errors/ diff --git a/.yamllint b/.yamllint index e587d75d799..d8387c634ee 100644 --- a/.yamllint +++ b/.yamllint @@ -1,5 +1,6 @@ ignore: | azure-*.yml + tests/fixtures/core/config/yaml_errors/ rules: braces: level: error diff --git a/tests/fixtures/core/config/yaml_errors/basic/configuration.yaml b/tests/fixtures/core/config/yaml_errors/basic/configuration.yaml new file mode 100644 index 00000000000..86292e7ab96 --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/basic/configuration.yaml @@ -0,0 +1,4 @@ +iot_domain: + # Indentation error + - platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/yaml_errors/basic_include/configuration.yaml b/tests/fixtures/core/config/yaml_errors/basic_include/configuration.yaml new file mode 100644 index 00000000000..7b343d41e9a --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/basic_include/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include integrations/iot_domain.yaml diff --git a/tests/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml b/tests/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml new file mode 100644 index 00000000000..4e01fecc74c --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml @@ -0,0 +1,3 @@ +# Indentation error +- platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/yaml_errors/include_dir_list/configuration.yaml b/tests/fixtures/core/config/yaml_errors/include_dir_list/configuration.yaml new file mode 100644 index 00000000000..bb0f052a39a --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/include_dir_list/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include_dir_list iot_domain diff --git a/tests/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml new file mode 100644 index 00000000000..5c01bd1b3c1 --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml @@ -0,0 +1,3 @@ +# Indentation error +platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/configuration.yaml b/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/configuration.yaml new file mode 100644 index 00000000000..e0c03e9f445 --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include_dir_merge_list iot_domain diff --git a/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml new file mode 100644 index 00000000000..4e01fecc74c --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml @@ -0,0 +1,3 @@ +# Indentation error +- platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/configuration.yaml new file mode 100644 index 00000000000..d3b52e4d49d --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/configuration.yaml @@ -0,0 +1,3 @@ +homeassistant: + # Load packages + packages: !include_dir_named integrations diff --git a/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml new file mode 100644 index 00000000000..f9f2f6e7319 --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml @@ -0,0 +1,4 @@ +# Indentation error +adr_0007_1: + host: blah.com + port: 123 diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index b94116f7bca..98580600f27 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -57,3 +57,73 @@ "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml:1). ", ]) # --- +# name: test_yaml_error[basic] + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/basic/configuration.yaml", line 4, column 14 + ''' +# --- +# name: test_yaml_error[basic].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/basic/configuration.yaml", line 4, column 14 + ''', + ]) +# --- +# name: test_yaml_error[basic_include] + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml", line 3, column 12 + ''' +# --- +# name: test_yaml_error[basic_include].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml", line 3, column 12 + ''', + ]) +# --- +# name: test_yaml_error[include_dir_list] + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml", line 3, column 10 + ''' +# --- +# name: test_yaml_error[include_dir_list].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml", line 3, column 10 + ''', + ]) +# --- +# name: test_yaml_error[include_dir_merge_list] + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml", line 3, column 12 + ''' +# --- +# name: test_yaml_error[include_dir_merge_list].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml", line 3, column 12 + ''', + ]) +# --- +# name: test_yaml_error[packages_include_dir_named] + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml", line 4, column 9 + ''' +# --- +# name: test_yaml_error[packages_include_dir_named].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml", line 4, column 9 + ''', + ]) +# --- diff --git a/tests/test_config.py b/tests/test_config.py index e8e45413117..d97d4f7a2c8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1540,3 +1540,40 @@ async def test_package_merge_error( if record.levelno == logging.ERROR ] assert error_records == snapshot + + +@pytest.mark.parametrize( + "config_dir", + [ + "basic", + "basic_include", + "include_dir_list", + "include_dir_merge_list", + "packages_include_dir_named", + ], +) +async def test_yaml_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + mock_iot_domain_integration: Integration, + mock_non_adr_0007_integration: None, + mock_adr_0007_integrations: list[Integration], + snapshot: SnapshotAssertion, +) -> None: + """Test schema error in component.""" + + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "yaml_errors", config_dir + ) + with pytest.raises(HomeAssistantError) as exc_info: + await config_util.async_hass_config_yaml(hass) + assert str(exc_info.value).replace(base_path, "") == snapshot + + error_records = [ + record.message.replace(base_path, "") + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot From a1356874ea7cdaea60acdbb797d530309efa633a Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 13 Nov 2023 15:13:06 +0200 Subject: [PATCH 435/982] Bump Islamic prayer times library to 0.0.10 (#103420) --- homeassistant/components/islamic_prayer_times/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/manifest.json b/homeassistant/components/islamic_prayer_times/manifest.json index c87cb2d28ac..7d2dd178788 100644 --- a/homeassistant/components/islamic_prayer_times/manifest.json +++ b/homeassistant/components/islamic_prayer_times/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times", "iot_class": "cloud_polling", "loggers": ["prayer_times_calculator"], - "requirements": ["prayer-times-calculator==0.0.6"] + "requirements": ["prayer-times-calculator==0.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7400dbf8000..9937a97a220 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1487,7 +1487,7 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer-times-calculator==0.0.6 +prayer-times-calculator==0.0.10 # homeassistant.components.proliphix proliphix==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6321e1e3c5a..c0b791bbf3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1141,7 +1141,7 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer-times-calculator==0.0.6 +prayer-times-calculator==0.0.10 # homeassistant.components.prometheus prometheus-client==0.17.1 From c6f9af2cabb8d1085baca23195883e5b910ac1b1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 13 Nov 2023 14:40:26 +0100 Subject: [PATCH 436/982] Reset mypy ci cache (#103910) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 29f0c9ee5d8..4b99c3ddc04 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,7 +35,7 @@ on: env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 - MYPY_CACHE_VERSION: 5 + MYPY_CACHE_VERSION: 6 BLACK_CACHE_VERSION: 1 HA_SHORT_VERSION: "2023.12" DEFAULT_PYTHON: "3.11" From 07af0737356149436047448db7b8ffb757d91f48 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Nov 2023 15:19:37 +0100 Subject: [PATCH 437/982] Remove trailing space from configuration error messages (#103909) * Remove trailing space from configuration error messages * Update test --- homeassistant/config.py | 6 ++-- tests/helpers/test_check_config.py | 2 +- tests/snapshots/test_config.ambr | 52 +++++++++++++++--------------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 61f3dd963af..abe14adb2ef 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -535,11 +535,11 @@ def _format_config_error( message += ( f" (See {getattr(domain_config, '__config_file__', '?')}, " - f"line {getattr(domain_config, '__line__', '?')}). " + f"line {getattr(domain_config, '__line__', '?')})." ) if domain != CONF_CORE and link: - message += f"Please check the docs at {link}" + message += f" Please check the docs at {link}" return message, is_friendly @@ -670,7 +670,7 @@ def _log_pkg_error(package: str, component: str, config: dict, message: str) -> pack_config = config[CONF_CORE][CONF_PACKAGES].get(package, config) message += ( f" (See {getattr(pack_config, '__config_file__', '?')}:" - f"{getattr(pack_config, '__line__', '?')}). " + f"{getattr(pack_config, '__line__', '?')})." ) _LOGGER.error(message) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 38c1b4913cd..a62bd8b39e4 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -83,7 +83,7 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: ( "Invalid config for [homeassistant]: not a valid value for dictionary " "value @ data['unit_system']. Got 'bad'. (See " - f"{hass.config.path(YAML_CONFIG_FILE)}, line 2). " + f"{hass.config.path(YAML_CONFIG_FILE)}, line 2)." ), "homeassistant", {"unit_system": "bad"}, diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 98580600f27..e7afa47537a 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,60 +1,60 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 6). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 9). ", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 20). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 6).", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 9).", + "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 20).", ]) # --- # name: test_component_config_validation_error[basic_include] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8). ", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 4). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5).", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8).", + "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 4).", ]) # --- # name: test_component_config_validation_error[include_dir_list] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 2). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2).", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 2).", ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 2). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 2).", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5).", ]) # --- # name: test_component_config_validation_error[packages] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 11). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 16). ", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 11).", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 16).", + "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?).", ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6). ", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9). ", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?). ", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?). ", + "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6).", + "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9).", + "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", + "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?).", ]) # --- # name: test_package_merge_error[packages] list([ - 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages/configuration.yaml:9). ', - 'Package pack_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages/configuration.yaml:13). ', - "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages/configuration.yaml:20). ", + 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages/configuration.yaml:9).', + 'Package pack_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages/configuration.yaml:13).', + "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages/configuration.yaml:20).", ]) # --- # name: test_package_merge_error[packages_include_dir_named] list([ - 'Package adr_0007_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml:2). ', - 'Package adr_0007_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml:2). ', - "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml:1). ", + 'Package adr_0007_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml:2).', + 'Package adr_0007_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml:2).', + "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml:1).", ]) # --- # name: test_yaml_error[basic] From f89194784dca57e2284974d695fea3121a164ab1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Nov 2023 15:23:50 +0100 Subject: [PATCH 438/982] Fix including yaml files with scalar values (#103914) --- homeassistant/util/yaml/loader.py | 6 ++- tests/util/yaml/test_init.py | 75 ++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 8a8822ab17f..6c2cfa1f953 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Iterator +from contextlib import suppress import fnmatch from io import StringIO, TextIOWrapper import logging @@ -230,8 +231,9 @@ def _add_reference( # type: ignore[no-untyped-def] obj = NodeListClass(obj) if isinstance(obj, str): obj = NodeStrClass(obj) - setattr(obj, "__config_file__", loader.get_name()) - setattr(obj, "__line__", node.start_mark.line + 1) + with suppress(AttributeError): + setattr(obj, "__config_file__", loader.get_name()) + setattr(obj, "__line__", node.start_mark.line + 1) return obj diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index d133e6f1088..990956ec908 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -110,7 +110,11 @@ def test_invalid_environment_variable(try_both_loaders) -> None: @pytest.mark.parametrize( ("hass_config_yaml_files", "value"), - [({"test.yaml": "value"}, "value"), ({"test.yaml": None}, {})], + [ + ({"test.yaml": "value"}, "value"), + ({"test.yaml": None}, {}), + ({"test.yaml": "123"}, 123), + ], ) def test_include_yaml( try_both_loaders, mock_hass_config_yaml: None, value: Any @@ -124,10 +128,14 @@ def test_include_yaml( @patch("homeassistant.util.yaml.loader.os.walk") @pytest.mark.parametrize( - "hass_config_yaml_files", [{"/test/one.yaml": "one", "/test/two.yaml": "two"}] + ("hass_config_yaml_files", "value"), + [ + ({"/test/one.yaml": "one", "/test/two.yaml": "two"}, ["one", "two"]), + ({"/test/one.yaml": "1", "/test/two.yaml": "2"}, [1, 2]), + ], ) def test_include_dir_list( - mock_walk, try_both_loaders, mock_hass_config_yaml: None + mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any ) -> None: """Test include dir list yaml.""" mock_walk.return_value = [["/test", [], ["two.yaml", "one.yaml"]]] @@ -135,7 +143,7 @@ def test_include_dir_list( conf = "key: !include_dir_list /test" with io.StringIO(conf) as file: doc = yaml_loader.parse_yaml(file) - assert doc["key"] == sorted(["one", "two"]) + assert sorted(doc["key"]) == sorted(value) @patch("homeassistant.util.yaml.loader.os.walk") @@ -170,11 +178,20 @@ def test_include_dir_list_recursive( @patch("homeassistant.util.yaml.loader.os.walk") @pytest.mark.parametrize( - "hass_config_yaml_files", - [{"/test/first.yaml": "one", "/test/second.yaml": "two"}], + ("hass_config_yaml_files", "value"), + [ + ( + {"/test/first.yaml": "one", "/test/second.yaml": "two"}, + {"first": "one", "second": "two"}, + ), + ( + {"/test/first.yaml": "1", "/test/second.yaml": "2"}, + {"first": 1, "second": 2}, + ), + ], ) def test_include_dir_named( - mock_walk, try_both_loaders, mock_hass_config_yaml: None + mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any ) -> None: """Test include dir named yaml.""" mock_walk.return_value = [ @@ -182,10 +199,9 @@ def test_include_dir_named( ] conf = "key: !include_dir_named /test" - correct = {"first": "one", "second": "two"} with io.StringIO(conf) as file: doc = yaml_loader.parse_yaml(file) - assert doc["key"] == correct + assert doc["key"] == value @patch("homeassistant.util.yaml.loader.os.walk") @@ -221,11 +237,20 @@ def test_include_dir_named_recursive( @patch("homeassistant.util.yaml.loader.os.walk") @pytest.mark.parametrize( - "hass_config_yaml_files", - [{"/test/first.yaml": "- one", "/test/second.yaml": "- two\n- three"}], + ("hass_config_yaml_files", "value"), + [ + ( + {"/test/first.yaml": "- one", "/test/second.yaml": "- two\n- three"}, + ["one", "two", "three"], + ), + ( + {"/test/first.yaml": "- 1", "/test/second.yaml": "- 2\n- 3"}, + [1, 2, 3], + ), + ], ) def test_include_dir_merge_list( - mock_walk, try_both_loaders, mock_hass_config_yaml: None + mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any ) -> None: """Test include dir merge list yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] @@ -233,7 +258,7 @@ def test_include_dir_merge_list( conf = "key: !include_dir_merge_list /test" with io.StringIO(conf) as file: doc = yaml_loader.parse_yaml(file) - assert sorted(doc["key"]) == sorted(["one", "two", "three"]) + assert sorted(doc["key"]) == sorted(value) @patch("homeassistant.util.yaml.loader.os.walk") @@ -268,16 +293,26 @@ def test_include_dir_merge_list_recursive( @patch("homeassistant.util.yaml.loader.os.walk") @pytest.mark.parametrize( - "hass_config_yaml_files", + ("hass_config_yaml_files", "value"), [ - { - "/test/first.yaml": "key1: one", - "/test/second.yaml": "key2: two\nkey3: three", - } + ( + { + "/test/first.yaml": "key1: one", + "/test/second.yaml": "key2: two\nkey3: three", + }, + {"key1": "one", "key2": "two", "key3": "three"}, + ), + ( + { + "/test/first.yaml": "key1: 1", + "/test/second.yaml": "key2: 2\nkey3: 3", + }, + {"key1": 1, "key2": 2, "key3": 3}, + ), ], ) def test_include_dir_merge_named( - mock_walk, try_both_loaders, mock_hass_config_yaml: None + mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any ) -> None: """Test include dir merge named yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] @@ -285,7 +320,7 @@ def test_include_dir_merge_named( conf = "key: !include_dir_merge_named /test" with io.StringIO(conf) as file: doc = yaml_loader.parse_yaml(file) - assert doc["key"] == {"key1": "one", "key2": "two", "key3": "three"} + assert doc["key"] == value @patch("homeassistant.util.yaml.loader.os.walk") From 818bc12f87f45e880d146281de9888db4fc3e72f Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 13 Nov 2023 15:41:06 +0100 Subject: [PATCH 439/982] Bump pyOverkiz to 1.13.2 (#103790) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index f57e351a282..cc9a410392a 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.0"], + "requirements": ["pyoverkiz==1.13.2"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 9937a97a220..2cfa59bebd1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1947,7 +1947,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.0 +pyoverkiz==1.13.2 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0b791bbf3c..afe6d90a2e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1469,7 +1469,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.0 +pyoverkiz==1.13.2 # homeassistant.components.openweathermap pyowm==3.2.0 From 0e4186ff8a6007a8a259fe7dc1bfbcf986c80a36 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 13 Nov 2023 15:42:51 +0100 Subject: [PATCH 440/982] Fix race condition in Matter unsubscribe method (#103770) --- homeassistant/components/matter/entity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 102e0c83b7b..7e7b7a688df 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast @@ -110,7 +111,9 @@ class MatterEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" for unsub in self._unsubscribes: - unsub() + with suppress(ValueError): + # suppress ValueError to prevent race conditions + unsub() @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: From ba3269540fe38f7109fe793cc6e49be06e4e6e93 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 13 Nov 2023 06:57:47 -0800 Subject: [PATCH 441/982] Cleanup CalDAV test fixtures (#103893) --- tests/components/caldav/conftest.py | 27 ++++++------------------ tests/components/caldav/test_calendar.py | 11 ++++++---- tests/components/caldav/test_init.py | 17 +++++++++------ tests/components/caldav/test_todo.py | 20 +++++++++++++----- 4 files changed, 39 insertions(+), 36 deletions(-) diff --git a/tests/components/caldav/conftest.py b/tests/components/caldav/conftest.py index 1c773d49166..504103afe13 100644 --- a/tests/components/caldav/conftest.py +++ b/tests/components/caldav/conftest.py @@ -1,5 +1,4 @@ """Test fixtures for caldav.""" -from collections.abc import Awaitable, Callable from unittest.mock import Mock, patch import pytest @@ -12,7 +11,6 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -27,6 +25,13 @@ def mock_platforms() -> list[Platform]: return [] +@pytest.fixture(autouse=True) +async def mock_patch_platforms(platforms: list[str]) -> None: + """Fixture to set up the integration.""" + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + yield + + @pytest.fixture(name="calendars") def mock_calendars() -> list[Mock]: """Fixture to provide calendars returned by CalDAV client.""" @@ -57,21 +62,3 @@ def mock_config_entry() -> MockConfigEntry: CONF_VERIFY_SSL: True, }, ) - - -@pytest.fixture(name="setup_integration") -async def mock_setup_integration( - hass: HomeAssistant, - config_entry: MockConfigEntry, - platforms: list[str], -) -> Callable[[], Awaitable[bool]]: - """Fixture to set up the integration.""" - config_entry.add_to_hass(hass) - - async def run() -> bool: - with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): - result = await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return result - - return run diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 5a648949f0f..df5428121ee 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator EVENTS = [ @@ -1085,10 +1086,11 @@ async def test_calendar_components(hass: HomeAssistant) -> None: @freeze_time(_local_datetime(17, 30)) async def test_setup_config_entry( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, ) -> None: """Test a calendar entity from a config entry.""" - assert await setup_integration() + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) state = hass.states.get(TEST_ENTITY) assert state @@ -1118,10 +1120,11 @@ async def test_setup_config_entry( ) async def test_config_entry_supported_components( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, ) -> None: """Test that calendars are only created for VEVENT types when using a config entry.""" - assert await setup_integration() + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) state = hass.states.get("calendar.calendar_1") assert state diff --git a/tests/components/caldav/test_init.py b/tests/components/caldav/test_init.py index a37815a007c..8e832e24d2d 100644 --- a/tests/components/caldav/test_init.py +++ b/tests/components/caldav/test_init.py @@ -1,6 +1,5 @@ """Unit tests for the CalDav integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import patch from caldav.lib.error import AuthorizationError, DAVError @@ -13,17 +12,21 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def mock_add_to_hass(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture to add the ConfigEntry.""" + config_entry.add_to_hass(hass) + + async def test_load_unload( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, ) -> None: """Test loading and unloading of the config entry.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED with patch("homeassistant.components.caldav.config_flow.caldav.DAVClient"): - assert await setup_integration() + await config_entry.async_setup(hass) assert config_entry.state == ConfigEntryState.LOADED @@ -47,8 +50,7 @@ async def test_load_unload( ) async def test_client_failure( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], - config_entry: MockConfigEntry | None, + config_entry: MockConfigEntry, side_effect: Exception, expected_state: ConfigEntryState, expected_flows: list[str], @@ -61,7 +63,8 @@ async def test_client_failure( "homeassistant.components.caldav.config_flow.caldav.DAVClient" ) as mock_client: mock_client.return_value.principal.side_effect = side_effect - assert not await setup_integration() + await config_entry.async_setup(hass) + await hass.async_block_till_done() assert config_entry.state == expected_state diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 16a95d418a8..352b60d5ed3 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -1,5 +1,4 @@ """The tests for the webdav todo component.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, Mock from caldav.objects import Todo @@ -8,6 +7,8 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + CALENDAR_NAME = "My Tasks" ENTITY_NAME = "My tasks" TEST_ENTITY = "todo.my_tasks" @@ -88,6 +89,15 @@ def mock_calendars(todos: list[str], supported_components: list[str]) -> list[Mo return [calendar] +@pytest.fixture(autouse=True) +async def mock_add_to_hass( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Fixture to add the ConfigEntry.""" + config_entry.add_to_hass(hass) + + @pytest.mark.parametrize( ("todos", "expected_state"), [ @@ -115,11 +125,11 @@ def mock_calendars(todos: list[str], supported_components: list[str]) -> list[Mo ) async def test_todo_list_state( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, expected_state: str, ) -> None: """Test a calendar entity from a config entry.""" - assert await setup_integration() + await config_entry.async_setup(hass) state = hass.states.get(TEST_ENTITY) assert state @@ -136,11 +146,11 @@ async def test_todo_list_state( ) async def test_supported_components( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, has_entity: bool, ) -> None: """Test a calendar supported components matches VTODO.""" - assert await setup_integration() + await config_entry.async_setup(hass) state = hass.states.get(TEST_ENTITY) assert (state is not None) == has_entity From 1e57bc5415ba596d5e5e1b2b5880c82c5b455ef9 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 13 Nov 2023 17:03:08 +0100 Subject: [PATCH 442/982] Add `number` state to prometheus metrics (#102518) --- .../components/prometheus/__init__.py | 16 +++-- tests/components/prometheus/test_init.py | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index c96ed2e4ed3..1ce16caa6e1 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -353,18 +353,18 @@ class PrometheusMetrics: value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_input_number(self, state): + def _numeric_handler(self, state, domain, title): if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): metric = self._metric( - f"input_number_state_{unit}", + f"{domain}_state_{unit}", self.prometheus_cli.Gauge, - f"State of the input number measured in {unit}", + f"State of the {title} measured in {unit}", ) else: metric = self._metric( - "input_number_state", + f"{domain}_state", self.prometheus_cli.Gauge, - "State of the input number", + f"State of the {title}", ) with suppress(ValueError): @@ -378,6 +378,12 @@ class PrometheusMetrics: ) metric.labels(**self._labels(state)).set(value) + def _handle_input_number(self, state): + self._numeric_handler(state, "input_number", "input number") + + def _handle_number(self, state): + self._numeric_handler(state, "number", "number") + def _handle_device_tracker(self, state): metric = self._metric( "device_tracker_state", diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index f24782b98d4..1e14ab848a0 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -20,6 +20,7 @@ from homeassistant.components import ( input_number, light, lock, + number, person, prometheus, sensor, @@ -292,6 +293,30 @@ async def test_input_number(client, input_number_entities) -> None: ) +@pytest.mark.parametrize("namespace", [""]) +async def test_number(client, number_entities) -> None: + """Test prometheus metrics for number.""" + body = await generate_latest_metrics(client) + + assert ( + 'number_state{domain="number",' + 'entity="number.threshold",' + 'friendly_name="Threshold"} 5.2' in body + ) + + assert ( + 'number_state{domain="number",' + 'entity="number.brightness",' + 'friendly_name="None"} 60.0' in body + ) + + assert ( + 'number_state_celsius{domain="number",' + 'entity="number.target_temperature",' + 'friendly_name="Target temperature"} 22.7' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_battery(client, sensor_entities) -> None: """Test prometheus metrics for battery.""" @@ -1382,6 +1407,46 @@ async def input_number_fixture( return data +@pytest.fixture(name="number_entities") +async def number_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: + """Simulate number entities.""" + data = {} + number_1 = entity_registry.async_get_or_create( + domain=number.DOMAIN, + platform="test", + unique_id="number_1", + suggested_object_id="threshold", + original_name="Threshold", + ) + set_state_with_entry(hass, number_1, 5.2) + data["number_1"] = number_1 + + number_2 = entity_registry.async_get_or_create( + domain=number.DOMAIN, + platform="test", + unique_id="number_2", + suggested_object_id="brightness", + ) + set_state_with_entry(hass, number_2, 60) + data["number_2"] = number_2 + + number_3 = entity_registry.async_get_or_create( + domain=number.DOMAIN, + platform="test", + unique_id="number_3", + suggested_object_id="target_temperature", + original_name="Target temperature", + unit_of_measurement=UnitOfTemperature.CELSIUS, + ) + set_state_with_entry(hass, number_3, 22.7) + data["number_3"] = number_3 + + await hass.async_block_till_done() + return data + + @pytest.fixture(name="input_boolean_entities") async def input_boolean_fixture( hass: HomeAssistant, entity_registry: er.EntityRegistry From 39c81cb4b1854ebae0d8e8070a02360e5f591b55 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 13 Nov 2023 17:09:27 +0100 Subject: [PATCH 443/982] Prefer IPv4 locations over IPv6 locations for upnp devices/component (#103792) --- homeassistant/components/ssdp/__init__.py | 81 ++++++++++---------- homeassistant/components/upnp/__init__.py | 8 +- homeassistant/components/upnp/config_flow.py | 17 ++-- homeassistant/components/upnp/device.py | 23 +++++- tests/components/upnp/conftest.py | 46 ++++++++++- tests/components/upnp/test_config_flow.py | 2 + tests/components/upnp/test_init.py | 34 ++++++++ 7 files changed, 153 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index ded663af897..a2df2c313cd 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -117,6 +117,7 @@ class SsdpServiceInfo(BaseServiceInfo): ssdp_ext: str | None = None ssdp_server: str | None = None ssdp_headers: Mapping[str, Any] = field(default_factory=dict) + ssdp_all_locations: set[str] = field(default_factory=set) x_homeassistant_matching_domains: set[str] = field(default_factory=set) @@ -283,6 +284,7 @@ class Scanner: self.hass = hass self._cancel_scan: Callable[[], None] | None = None self._ssdp_listeners: list[SsdpListener] = [] + self._device_tracker = SsdpDeviceTracker() self._callbacks: list[tuple[SsdpCallback, dict[str, str]]] = [] self._description_cache: DescriptionCache | None = None self.integration_matchers = integration_matchers @@ -290,21 +292,7 @@ class Scanner: @property def _ssdp_devices(self) -> list[SsdpDevice]: """Get all seen devices.""" - return [ - ssdp_device - for ssdp_listener in self._ssdp_listeners - for ssdp_device in ssdp_listener.devices.values() - ] - - @property - def _all_headers_from_ssdp_devices( - self, - ) -> dict[tuple[str, str], CaseInsensitiveDict]: - return { - (ssdp_device.udn, dst): headers - for ssdp_device in self._ssdp_devices - for dst, headers in ssdp_device.all_combined_headers.items() - } + return list(self._device_tracker.devices.values()) async def async_register_callback( self, callback: SsdpCallback, match_dict: None | dict[str, str] = None @@ -317,13 +305,16 @@ class Scanner: # Make sure any entries that happened # before the callback was registered are fired - for headers in self._all_headers_from_ssdp_devices.values(): - if _async_headers_match(headers, lower_match_dict): - await _async_process_callbacks( - [callback], - await self._async_headers_to_discovery_info(headers), - SsdpChange.ALIVE, - ) + for ssdp_device in self._ssdp_devices: + for headers in ssdp_device.all_combined_headers.values(): + if _async_headers_match(headers, lower_match_dict): + await _async_process_callbacks( + [callback], + await self._async_headers_to_discovery_info( + ssdp_device, headers + ), + SsdpChange.ALIVE, + ) callback_entry = (callback, lower_match_dict) self._callbacks.append(callback_entry) @@ -386,7 +377,6 @@ class Scanner: async def _async_start_ssdp_listeners(self) -> None: """Start the SSDP Listeners.""" # Devices are shared between all sources. - device_tracker = SsdpDeviceTracker() for source_ip in await async_build_source_set(self.hass): source_ip_str = str(source_ip) if source_ip.version == 6: @@ -405,7 +395,7 @@ class Scanner: callback=self._ssdp_listener_callback, source=source, target=target, - device_tracker=device_tracker, + device_tracker=self._device_tracker, ) ) results = await asyncio.gather( @@ -454,14 +444,16 @@ class Scanner: if info_desc is None: # Fetch info desc in separate task and process from there. self.hass.async_create_task( - self._ssdp_listener_process_with_lookup(ssdp_device, dst, source) + self._ssdp_listener_process_callback_with_lookup( + ssdp_device, dst, source + ) ) return # Info desc known, process directly. - self._ssdp_listener_process(ssdp_device, dst, source, info_desc) + self._ssdp_listener_process_callback(ssdp_device, dst, source, info_desc) - async def _ssdp_listener_process_with_lookup( + async def _ssdp_listener_process_callback_with_lookup( self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, @@ -469,14 +461,14 @@ class Scanner: ) -> None: """Handle a device/service change.""" location = ssdp_device.location - self._ssdp_listener_process( + self._ssdp_listener_process_callback( ssdp_device, dst, source, await self._async_get_description_dict(location), ) - def _ssdp_listener_process( + def _ssdp_listener_process_callback( self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, @@ -502,7 +494,7 @@ class Scanner: return discovery_info = discovery_info_from_headers_and_description( - combined_headers, info_desc + ssdp_device, combined_headers, info_desc ) discovery_info.x_homeassistant_matching_domains = matching_domains @@ -557,7 +549,7 @@ class Scanner: return await self._description_cache.async_get_description_dict(location) or {} async def _async_headers_to_discovery_info( - self, headers: CaseInsensitiveDict + self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict ) -> SsdpServiceInfo: """Combine the headers and description into discovery_info. @@ -567,34 +559,42 @@ class Scanner: location = headers["location"] info_desc = await self._async_get_description_dict(location) - return discovery_info_from_headers_and_description(headers, info_desc) + return discovery_info_from_headers_and_description( + ssdp_device, headers, info_desc + ) async def async_get_discovery_info_by_udn_st( self, udn: str, st: str ) -> SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" - if headers := self._all_headers_from_ssdp_devices.get((udn, st)): - return await self._async_headers_to_discovery_info(headers) + for ssdp_device in self._ssdp_devices: + if ssdp_device.udn == udn: + if headers := ssdp_device.combined_headers(st): + return await self._async_headers_to_discovery_info( + ssdp_device, headers + ) return None async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ - await self._async_headers_to_discovery_info(headers) - for udn_st, headers in self._all_headers_from_ssdp_devices.items() - if udn_st[1] == st + await self._async_headers_to_discovery_info(ssdp_device, headers) + for ssdp_device in self._ssdp_devices + if (headers := ssdp_device.combined_headers(st)) ] async def async_get_discovery_info_by_udn(self, udn: str) -> list[SsdpServiceInfo]: """Return matching discovery_infos for a udn.""" return [ - await self._async_headers_to_discovery_info(headers) - for udn_st, headers in self._all_headers_from_ssdp_devices.items() - if udn_st[0] == udn + await self._async_headers_to_discovery_info(ssdp_device, headers) + for ssdp_device in self._ssdp_devices + for headers in ssdp_device.all_combined_headers.values() + if ssdp_device.udn == udn ] def discovery_info_from_headers_and_description( + ssdp_device: SsdpDevice, combined_headers: CaseInsensitiveDict, info_desc: Mapping[str, Any], ) -> SsdpServiceInfo: @@ -627,6 +627,7 @@ def discovery_info_from_headers_and_description( ssdp_nt=combined_headers.get_lower("nt"), ssdp_headers=combined_headers, upnp=upnp_info, + ssdp_all_locations=set(ssdp_device.locations), ) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 326ff5d7651..6af9d85bc87 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -26,7 +26,7 @@ from .const import ( LOGGER, ) from .coordinator import UpnpDataUpdateCoordinator -from .device import async_create_device +from .device import async_create_device, get_preferred_location NOTIFICATION_ID = "upnp_notification" NOTIFICATION_TITLE = "UPnP/IGD Setup" @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return nonlocal discovery_info - LOGGER.debug("Device discovered: %s, at: %s", usn, headers.ssdp_location) + LOGGER.debug("Device discovered: %s, at: %s", usn, headers.ssdp_all_locations) discovery_info = headers device_discovered_event.set() @@ -79,8 +79,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create device. assert discovery_info is not None - assert discovery_info.ssdp_location is not None - location = discovery_info.ssdp_location + assert discovery_info.ssdp_all_locations + location = get_preferred_location(discovery_info.ssdp_all_locations) try: device = await async_create_device(hass, location) except UpnpConnectionError as err: diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 35d66536375..b32273a3f24 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any, cast +from urllib.parse import urlparse import voluptuous as vol @@ -25,7 +26,7 @@ from .const import ( ST_IGD_V1, ST_IGD_V2, ) -from .device import async_get_mac_address_from_host +from .device import async_get_mac_address_from_host, get_preferred_location def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str: @@ -43,7 +44,7 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: return bool( ssdp.ATTR_UPNP_UDN in discovery_info.upnp and discovery_info.ssdp_st - and discovery_info.ssdp_location + and discovery_info.ssdp_all_locations and discovery_info.ssdp_usn ) @@ -61,7 +62,9 @@ async def _async_mac_address_from_discovery( hass: HomeAssistant, discovery: SsdpServiceInfo ) -> str | None: """Get the mac address from a discovery.""" - host = discovery.ssdp_headers["_host"] + location = get_preferred_location(discovery.ssdp_all_locations) + host = urlparse(location).hostname + assert host is not None return await async_get_mac_address_from_host(hass, host) @@ -178,7 +181,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # when the location changes, the entry is reloaded. updates={ CONFIG_ENTRY_MAC_ADDRESS: mac_address, - CONFIG_ENTRY_LOCATION: discovery_info.ssdp_location, + CONFIG_ENTRY_LOCATION: get_preferred_location( + discovery_info.ssdp_all_locations + ), CONFIG_ENTRY_HOST: host, CONFIG_ENTRY_ST: discovery_info.ssdp_st, }, @@ -249,7 +254,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONFIG_ENTRY_ORIGINAL_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_MAC_ADDRESS: mac_address, CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"], - CONFIG_ENTRY_LOCATION: discovery.ssdp_location, + CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations), } await self.async_set_unique_id(user_input["unique_id"], raise_on_progress=False) @@ -271,7 +276,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONFIG_ENTRY_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_ST: discovery.ssdp_st, CONFIG_ENTRY_ORIGINAL_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], - CONFIG_ENTRY_LOCATION: discovery.ssdp_location, + CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations), CONFIG_ENTRY_MAC_ADDRESS: mac_address, CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"], } diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index b62edbf9bc2..93f551bea37 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -33,6 +33,22 @@ from .const import ( ) +def get_preferred_location(locations: set[str]) -> str: + """Get the preferred location (an IPv4 location) from a set of locations.""" + # Prefer IPv4 over IPv6. + for location in locations: + if location.startswith("http://[") or location.startswith("https://["): + continue + + return location + + # Fallback to any. + for location in locations: + return location + + raise ValueError("No location found") + + async def async_get_mac_address_from_host(hass: HomeAssistant, host: str) -> str | None: """Get mac address from host.""" ip_addr = ip_address(host) @@ -47,13 +63,13 @@ async def async_get_mac_address_from_host(hass: HomeAssistant, host: str) -> str return mac_address -async def async_create_device(hass: HomeAssistant, ssdp_location: str) -> Device: +async def async_create_device(hass: HomeAssistant, location: str) -> Device: """Create UPnP/IGD device.""" session = async_get_clientsession(hass, verify_ssl=False) requester = AiohttpSessionRequester(session, with_sleep=True, timeout=20) factory = UpnpFactory(requester, non_strict=True) - upnp_device = await factory.async_create_device(ssdp_location) + upnp_device = await factory.async_create_device(location) # Create profile wrapper. igd_device = IgdDevice(upnp_device, None) @@ -119,8 +135,7 @@ class Device: @property def host(self) -> str | None: """Get the hostname.""" - url = self._igd_device.device.device_url - parsed = urlparse(url) + parsed = urlparse(self.device_url) return parsed.hostname @property diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 0952b14303d..87176c57692 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -1,6 +1,7 @@ """Configuration for SSDP tests.""" from __future__ import annotations +import copy from datetime import datetime from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch from urllib.parse import urlparse @@ -26,6 +27,7 @@ TEST_UDN = "uuid:device" TEST_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" TEST_USN = f"{TEST_UDN}::{TEST_ST}" TEST_LOCATION = "http://192.168.1.1/desc.xml" +TEST_LOCATION6 = "http://[fe80::1%2]/desc.xml" TEST_HOST = urlparse(TEST_LOCATION).hostname TEST_FRIENDLY_NAME = "mock-name" TEST_MAC_ADDRESS = "00:11:22:33:44:55" @@ -48,11 +50,23 @@ TEST_DISCOVERY = ssdp.SsdpServiceInfo( ssdp_headers={ "_host": TEST_HOST, }, + ssdp_all_locations={ + TEST_LOCATION, + }, ) +@pytest.fixture +def mock_async_create_device(): + """Mock async_upnp_client create device.""" + with patch( + "homeassistant.components.upnp.device.UpnpFactory.async_create_device" + ) as mock_create: + yield mock_create + + @pytest.fixture(autouse=True) -def mock_igd_device() -> IgdDevice: +def mock_igd_device(mock_async_create_device) -> IgdDevice: """Mock async_upnp_client device.""" mock_upnp_device = create_autospec(UpnpDevice, instance=True) mock_upnp_device.device_url = TEST_DISCOVERY.ssdp_location @@ -85,8 +99,6 @@ def mock_igd_device() -> IgdDevice: ) with patch( - "homeassistant.components.upnp.device.UpnpFactory.async_create_device" - ), patch( "homeassistant.components.upnp.device.IgdDevice.__new__", return_value=mock_igd_device, ): @@ -140,7 +152,7 @@ async def silent_ssdp_scanner(hass): @pytest.fixture async def ssdp_instant_discovery(): - """Instance discovery.""" + """Instant discovery.""" # Set up device discovery callback. async def register_callback(hass, callback, match_dict): @@ -158,6 +170,30 @@ async def ssdp_instant_discovery(): yield (mock_register, mock_get_info) +@pytest.fixture +async def ssdp_instant_discovery_multi_location(): + """Instant discovery.""" + + test_discovery = copy.deepcopy(TEST_DISCOVERY) + test_discovery.ssdp_location = TEST_LOCATION6 # "Default" location is IPv6. + test_discovery.ssdp_all_locations = {TEST_LOCATION6, TEST_LOCATION} + + # Set up device discovery callback. + async def register_callback(hass, callback, match_dict): + """Immediately do callback.""" + await callback(test_discovery, ssdp.SsdpChange.ALIVE) + return MagicMock() + + with patch( + "homeassistant.components.ssdp.async_register_callback", + side_effect=register_callback, + ) as mock_register, patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[test_discovery], + ) as mock_get_info: + yield (mock_register, mock_get_info) + + @pytest.fixture async def ssdp_no_discovery(): """No discovery.""" @@ -197,6 +233,8 @@ async def mock_config_entry( CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, ) + + # Store igd_device for binary_sensor/sensor tests. entry.igd_device = mock_igd_device # Load config_entry. diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 4c69b6f6875..7c542e33c9d 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -134,6 +134,7 @@ async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: ssdp_usn=TEST_USN, ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, + ssdp_all_locations=[TEST_LOCATION], upnp={ ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD ssdp.ATTR_UPNP_UDN: TEST_UDN, @@ -324,6 +325,7 @@ async def test_flow_ssdp_discovery_changed_location(hass: HomeAssistant) -> None new_location = TEST_DISCOVERY.ssdp_location + "2" new_discovery = deepcopy(TEST_DISCOVERY) new_discovery.ssdp_location = new_location + new_discovery.ssdp_all_locations = {new_location} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index e775757cb1f..d1d3dfa6c35 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -1,6 +1,8 @@ """Test UPnP/IGD setup process.""" from __future__ import annotations +from unittest.mock import AsyncMock + import pytest from homeassistant.components.upnp.const import ( @@ -60,3 +62,35 @@ async def test_async_setup_entry_default_no_mac_address(hass: HomeAssistant) -> # Load config_entry. entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) is True + + +@pytest.mark.usefixtures( + "ssdp_instant_discovery_multi_location", + "mock_get_source_ip", + "mock_mac_address_from_host", +) +async def test_async_setup_entry_multi_location( + hass: HomeAssistant, mock_async_create_device: AsyncMock +) -> None: + """Test async_setup_entry for a device both seen via IPv4 and IPv6. + + The resulting IPv4 location is preferred/stored. + """ + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USN, + data={ + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ORIGINAL_UDN: TEST_UDN, + CONFIG_ENTRY_LOCATION: TEST_LOCATION, + CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, + }, + ) + + # Load config_entry. + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is True + + # Ensure that the IPv4 location is used. + mock_async_create_device.assert_called_once_with(TEST_LOCATION) From 7ef47da27d182407db0a86799f7089ac12308f05 Mon Sep 17 00:00:00 2001 From: r01k Date: Mon, 13 Nov 2023 09:40:57 -0700 Subject: [PATCH 444/982] Add HTTPS support for Fully Kiosk (#89592) * Add HTTPS support for Fully Kiosk with optional certificate verification. * All pytests passing. * Better readability for url parameter of DeviceInfo * All pytests passing with latest fixes from upstream * Removing fully_kiosk/translations * Rebasing * Added extra error detail when the integration config flow fails --------- Co-authored-by: Erik Montnemery --- .../components/fully_kiosk/config_flow.py | 30 ++++++++++++-- .../components/fully_kiosk/coordinator.py | 5 ++- .../components/fully_kiosk/entity.py | 11 ++++- .../components/fully_kiosk/strings.json | 8 ++-- tests/components/fully_kiosk/conftest.py | 10 ++++- .../fully_kiosk/test_config_flow.py | 40 +++++++++++++++++-- tests/components/fully_kiosk/test_init.py | 12 +++++- 7 files changed, 103 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 7d744214d93..4f9dadd6901 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -12,7 +12,13 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -31,13 +37,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_device_info: dict[str, Any] = {} async def _create_entry( - self, host: str, user_input: dict[str, Any], errors: dict[str, str] + self, + host: str, + user_input: dict[str, Any], + errors: dict[str, str], + description_placeholders: dict[str, str] | Any = None, ) -> FlowResult | None: fully = FullyKiosk( async_get_clientsession(self.hass), host, DEFAULT_PORT, user_input[CONF_PASSWORD], + use_ssl=user_input[CONF_SSL], + verify_ssl=user_input[CONF_VERIFY_SSL], ) try: @@ -50,10 +62,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) as error: LOGGER.debug(error.args, exc_info=True) errors["base"] = "cannot_connect" + description_placeholders["error_detail"] = str(error.args) return None except Exception as error: # pylint: disable=broad-except LOGGER.exception("Unexpected exception: %s", error) errors["base"] = "unknown" + description_placeholders["error_detail"] = str(error.args) return None await self.async_set_unique_id(device_info["deviceID"], raise_on_progress=False) @@ -64,6 +78,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_HOST: host, CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_MAC: format_mac(device_info["Mac"]), + CONF_SSL: user_input[CONF_SSL], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], }, ) @@ -72,8 +88,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + placeholders: dict[str, str] = {} if user_input is not None: - result = await self._create_entry(user_input[CONF_HOST], user_input, errors) + result = await self._create_entry( + user_input[CONF_HOST], user_input, errors, placeholders + ) if result: return result @@ -83,8 +102,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required(CONF_HOST): str, vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, } ), + description_placeholders=placeholders, errors=errors, ) @@ -127,6 +149,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, } ), description_placeholders=placeholders, diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 0cfc15268b4..17facb79dbb 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -6,7 +6,7 @@ from fullykiosk import FullyKiosk from fullykiosk.exceptions import FullyKioskError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,6 +24,8 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): entry.data[CONF_HOST], DEFAULT_PORT, entry.data[CONF_PASSWORD], + use_ssl=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], ) super().__init__( hass, @@ -31,6 +33,7 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): name=entry.data[CONF_HOST], update_interval=UPDATE_INTERVAL, ) + self.use_ssl = entry.data[CONF_SSL] async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index fcb6f35eb11..87c441dd545 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -1,6 +1,8 @@ """Base entity for the Fully Kiosk Browser integration.""" from __future__ import annotations +from yarl import URL + from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity @@ -30,13 +32,20 @@ class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entit def __init__(self, coordinator: FullyKioskDataUpdateCoordinator) -> None: """Initialize the Fully Kiosk Browser entity.""" super().__init__(coordinator=coordinator) + + url = URL.build( + scheme="https" if coordinator.use_ssl else "http", + host=coordinator.data["ip4"], + port=2323, + ) + device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.data["deviceID"])}, name=coordinator.data["deviceName"], manufacturer=coordinator.data["deviceManufacturer"], model=coordinator.data["deviceModel"], sw_version=coordinator.data["appVersionName"], - configuration_url=f"http://{coordinator.data['ip4']}:2323", + configuration_url=str(url), ) if "Mac" in coordinator.data and valid_global_mac_address( coordinator.data["Mac"] diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index d61e8a7b7a8..bf46feeec3f 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -10,13 +10,15 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "cannot_connect": "Cannot connect. Details: {error_detail}", + "unknown": "Unknown. Details: {error_detail}" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" diff --git a/tests/components/fully_kiosk/conftest.py b/tests/components/fully_kiosk/conftest.py index bed08b532fd..e409a0a3787 100644 --- a/tests/components/fully_kiosk/conftest.py +++ b/tests/components/fully_kiosk/conftest.py @@ -8,7 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.fully_kiosk.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -24,6 +30,8 @@ def mock_config_entry() -> MockConfigEntry: CONF_HOST: "127.0.0.1", CONF_PASSWORD: "mocked-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, unique_id="12345", ) diff --git a/tests/components/fully_kiosk/test_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py index 566f3b6d292..018a62b5dc7 100644 --- a/tests/components/fully_kiosk/test_config_flow.py +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -10,7 +10,13 @@ import pytest from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_MQTT, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.mqtt import MqttServiceInfo @@ -35,6 +41,8 @@ async def test_user_flow( { CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) @@ -44,6 +52,8 @@ async def test_user_flow( CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } assert "result" in result2 assert result2["result"].unique_id == "12345" @@ -76,7 +86,13 @@ async def test_errors( mock_fully_kiosk_config_flow.getDeviceInfo.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password"} + flow_id, + user_input={ + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, ) assert result2.get("type") == FlowResultType.FORM @@ -88,7 +104,13 @@ async def test_errors( mock_fully_kiosk_config_flow.getDeviceInfo.side_effect = None result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password"} + flow_id, + user_input={ + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, ) assert result3.get("type") == FlowResultType.CREATE_ENTRY @@ -97,6 +119,8 @@ async def test_errors( CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: True, + CONF_VERIFY_SSL: False, } assert "result" in result3 assert result3["result"].unique_id == "12345" @@ -124,6 +148,8 @@ async def test_duplicate_updates_existing_entry( { CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", + CONF_SSL: True, + CONF_VERIFY_SSL: True, }, ) @@ -133,6 +159,8 @@ async def test_duplicate_updates_existing_entry( CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: True, + CONF_VERIFY_SSL: True, } assert len(mock_fully_kiosk_config_flow.getDeviceInfo.mock_calls) == 1 @@ -161,6 +189,8 @@ async def test_dhcp_discovery_updates_entry( CONF_HOST: "127.0.0.2", CONF_PASSWORD: "mocked-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } @@ -212,6 +242,8 @@ async def test_mqtt_discovery_flow( result["flow_id"], { CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) @@ -222,6 +254,8 @@ async def test_mqtt_discovery_flow( CONF_HOST: "192.168.1.234", CONF_PASSWORD: "test-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } assert "result" in confirmResult assert confirmResult["result"].unique_id == "12345" diff --git a/tests/components/fully_kiosk/test_init.py b/tests/components/fully_kiosk/test_init.py index 5c77b8a9d06..2e77cdb2f1d 100644 --- a/tests/components/fully_kiosk/test_init.py +++ b/tests/components/fully_kiosk/test_init.py @@ -9,7 +9,13 @@ import pytest from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.components.fully_kiosk.entity import valid_global_mac_address from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -92,6 +98,8 @@ async def test_multiple_kiosk_with_empty_mac( CONF_HOST: "127.0.0.1", CONF_PASSWORD: "mocked-password", CONF_MAC: "", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, unique_id="111111", ) @@ -105,6 +113,8 @@ async def test_multiple_kiosk_with_empty_mac( CONF_HOST: "127.0.0.2", CONF_PASSWORD: "mocked-password", CONF_MAC: "", + CONF_SSL: True, + CONF_VERIFY_SSL: False, }, unique_id="22222", ) From 067ece97b81922d0dfe49446f3bf548d789942a7 Mon Sep 17 00:00:00 2001 From: Manuel Richarz Date: Mon, 13 Nov 2023 17:41:22 +0100 Subject: [PATCH 445/982] Add support to fints for configuring unsupported account_types (#83537) * Possibility to configure unsupported account_types Changed conditions to be able to configure unsupported account_types like: Loan, Creditcards, Call money, etcpp. Those accounts won't be added automatically. But with this fix you can add them manually via configuration if needed. * chore: add integration_type for fints fix: lint error chore: add more info to warning for debugging purpose * Possibility to configure unsupported account_types Changed conditions to be able to configure unsupported account_types like: Loan, Creditcards, Call money, etcpp. Those accounts won't be added automatically. But with this fix you can add them manually via configuration if needed. * chore: broken merge * fix: remove version from manifest.json --------- Co-authored-by: Erik Montnemery --- homeassistant/components/fints/manifest.json | 1 + homeassistant/components/fints/sensor.py | 36 +++++++++++--------- homeassistant/generated/integrations.json | 2 +- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json index 821298434d9..063e612d35d 100644 --- a/homeassistant/components/fints/manifest.json +++ b/homeassistant/components/fints/manifest.json @@ -3,6 +3,7 @@ "name": "FinTS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/fints", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["fints", "mt_940", "sepaxml"], "requirements": ["fints==3.1.0"] diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 3b961054544..fafe1fcf2bf 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -168,14 +168,13 @@ class FinTsClient: if not account_information: return False - if not account_information["type"]: - # bank does not support account types, use value from config - if ( - account_information["iban"] in self.account_config - or account_information["account_number"] in self.account_config - ): - return True - elif 1 <= account_information["type"] <= 9: + if 1 <= account_information["type"] <= 9: + return True + + if ( + account_information["iban"] in self.account_config + or account_information["account_number"] in self.account_config + ): return True return False @@ -189,14 +188,13 @@ class FinTsClient: if not account_information: return False - if not account_information["type"]: - # bank does not support account types, use value from config - if ( - account_information["iban"] in self.holdings_config - or account_information["account_number"] in self.holdings_config - ): - return True - elif 30 <= account_information["type"] <= 39: + if 30 <= account_information["type"] <= 39: + return True + + if ( + account_information["iban"] in self.holdings_config + or account_information["account_number"] in self.holdings_config + ): return True return False @@ -215,7 +213,11 @@ class FinTsClient: holdings_accounts.append(account) else: - _LOGGER.warning("Could not determine type of account %s", account.iban) + _LOGGER.warning( + "Could not determine type of account %s from %s", + account.iban, + self.client.user_id, + ) return balance_accounts, holdings_accounts diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7680463cbd2..f59312073a6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1718,7 +1718,7 @@ }, "fints": { "name": "FinTS", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "cloud_polling" }, From 7ab4d9793ad567dd684cec9e2633778b81712d61 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 13 Nov 2023 19:09:46 +0100 Subject: [PATCH 446/982] Bump aiocomelit to 0.5.2 (#103791) * Bump aoicomelit to 0.5.0 * bump to 0.5.2 --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 5978f17cfc4..77796ac7e7f 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.3.0"] + "requirements": ["aiocomelit==0.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2cfa59bebd1..7c821ee55a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.3.0 +aiocomelit==0.5.2 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afe6d90a2e6..00613925454 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,7 +197,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.3.0 +aiocomelit==0.5.2 # homeassistant.components.dhcp aiodiscover==1.5.1 From 1610dd94f96dbaaa0e73b2a3ce06e309fb38f4a5 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Mon, 13 Nov 2023 18:33:42 +0000 Subject: [PATCH 447/982] Add 'do not edit' comment to generated files (#103923) --- homeassistant/package_constraints.txt | 2 ++ requirements.txt | 2 ++ requirements_all.txt | 2 ++ script/gen_requirements_all.py | 12 +++++++++--- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ebbe9686044..c6a52301044 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,3 +1,5 @@ +# Automatically generated by gen_requirements_all.py, do not edit + aiodiscover==1.5.1 aiohttp-fast-url-dispatcher==0.1.0 aiohttp-zlib-ng==0.1.1 diff --git a/requirements.txt b/requirements.txt index 1ca4643a747..f751354a4a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +# Automatically generated by gen_requirements_all.py, do not edit + -c homeassistant/package_constraints.txt # Home Assistant Core diff --git a/requirements_all.txt b/requirements_all.txt index 7c821ee55a3..61ea011b1b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,4 +1,6 @@ # Home Assistant Core, full dependency set +# Automatically generated by gen_requirements_all.py, do not edit + -r requirements.txt # homeassistant.components.aemet diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index cb202ed0466..2c442ed9796 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -182,6 +182,10 @@ get-mac==1000000000.0.0 charset-normalizer==3.2.0 """ +GENERATED_MESSAGE = ( + f"# Automatically generated by {Path(__file__).name}, do not edit\n\n" +) + IGNORE_PRE_COMMIT_HOOK_ID = ( "check-executables-have-shebangs", "check-json", @@ -354,6 +358,7 @@ def generate_requirements_list(reqs: dict[str, list[str]]) -> str: def requirements_output() -> str: """Generate output for requirements.""" output = [ + GENERATED_MESSAGE, "-c homeassistant/package_constraints.txt\n", "\n", "# Home Assistant Core\n", @@ -368,6 +373,7 @@ def requirements_all_output(reqs: dict[str, list[str]]) -> str: """Generate output for requirements_all.""" output = [ "# Home Assistant Core, full dependency set\n", + GENERATED_MESSAGE, "-r requirements.txt\n", ] output.append(generate_requirements_list(reqs)) @@ -379,8 +385,7 @@ def requirements_test_all_output(reqs: dict[str, list[str]]) -> str: """Generate output for test_requirements.""" output = [ "# Home Assistant tests, full dependency set\n", - f"# Automatically generated by {Path(__file__).name}, do not edit\n", - "\n", + GENERATED_MESSAGE, "-r requirements_test.txt\n", ] @@ -425,7 +430,8 @@ def requirements_pre_commit_output() -> str: def gather_constraints() -> str: """Construct output for constraint file.""" return ( - "\n".join( + GENERATED_MESSAGE + + "\n".join( sorted( { *core_requirements(), From f0a455e5c74ff5f101cc7b35b7ebd22f0b445d7a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 13 Nov 2023 19:37:58 +0100 Subject: [PATCH 448/982] Update icmplib privilege detection function to be async in ping integration (#103925) * Make icmplib privilege detection function async * I should also commit the tests.. --- homeassistant/components/ping/__init__.py | 10 +++++----- tests/components/ping/test_binary_sensor.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 26dd8113231..df1f7ebc9e5 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass import logging -from icmplib import SocketPermissionError, ping as icmp_ping +from icmplib import SocketPermissionError, async_ping from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -30,19 +30,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_setup_reload_service(hass, DOMAIN, PLATFORMS) hass.data[DOMAIN] = PingDomainData( - privileged=await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), + privileged=await _can_use_icmp_lib_with_privilege(), ) return True -def _can_use_icmp_lib_with_privilege() -> None | bool: +async def _can_use_icmp_lib_with_privilege() -> None | bool: """Verify we can create a raw socket.""" try: - icmp_ping("127.0.0.1", count=0, timeout=0, privileged=True) + await async_ping("127.0.0.1", count=0, timeout=0, privileged=True) except SocketPermissionError: try: - icmp_ping("127.0.0.1", count=0, timeout=0, privileged=False) + await async_ping("127.0.0.1", count=0, timeout=0, privileged=False) except SocketPermissionError: _LOGGER.debug( "Cannot use icmplib because privileges are insufficient to create the" diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index 3389534483f..b9bdc917e70 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -14,7 +14,7 @@ from tests.common import get_fixture_path @pytest.fixture def mock_ping() -> None: """Mock icmplib.ping.""" - with patch("homeassistant.components.ping.icmp_ping"): + with patch("homeassistant.components.ping.async_ping"): yield From 1a3475ea606e8ccec1e98e08dec403e2b621a234 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Mon, 13 Nov 2023 19:00:09 +0000 Subject: [PATCH 449/982] Fix typing for entity_platform.async_register_entity_service (#103777) --- homeassistant/helpers/entity_platform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 388c00bd177..2fc82567739 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -813,7 +813,7 @@ class EntityPlatform: def async_register_entity_service( self, name: str, - schema: dict[str, Any] | vol.Schema, + schema: dict[str | vol.Marker, Any] | vol.Schema, func: str | Callable[..., Any], required_features: Iterable[int] | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, From 0eafc8f2cdd0c3c334de08ba70a53a32d022f4f0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 13 Nov 2023 20:02:33 +0100 Subject: [PATCH 450/982] Update k-l* tests to use entity & device registry fixtures (#103929) --- tests/components/kaleidescape/test_init.py | 2 +- .../kaleidescape/test_media_player.py | 2 +- tests/components/kaleidescape/test_sensor.py | 5 +-- tests/components/knx/test_binary_sensor.py | 5 ++- tests/components/kraken/test_sensor.py | 5 +-- tests/components/lametric/test_helpers.py | 3 +- tests/components/lametric/test_services.py | 4 +-- .../landisgyr_heat_meter/test_init.py | 9 +++--- tests/components/lcn/test_binary_sensor.py | 5 +-- tests/components/lcn/test_cover.py | 5 +-- tests/components/lcn/test_device_trigger.py | 3 +- tests/components/lcn/test_init.py | 9 ++++-- tests/components/lcn/test_light.py | 6 ++-- tests/components/lcn/test_sensor.py | 5 +-- tests/components/lcn/test_switch.py | 5 +-- tests/components/lidarr/test_init.py | 6 ++-- tests/components/lifx/test_binary_sensor.py | 5 +-- tests/components/lifx/test_button.py | 10 +++--- tests/components/lifx/test_config_flow.py | 8 +++-- tests/components/lifx/test_light.py | 31 ++++++++++++------- tests/components/lifx/test_select.py | 10 +++--- tests/components/lifx/test_sensor.py | 10 +++--- tests/components/litejet/test_scene.py | 8 ++--- tests/components/litterrobot/test_button.py | 5 +-- tests/components/litterrobot/test_init.py | 11 ++++--- tests/components/litterrobot/test_vacuum.py | 20 ++++++------ tests/components/lock/test_init.py | 4 +-- tests/components/luftdaten/test_sensor.py | 4 +-- tests/components/lutron_caseta/test_button.py | 6 ++-- tests/components/lutron_caseta/test_cover.py | 6 ++-- tests/components/lutron_caseta/test_fan.py | 6 ++-- tests/components/lutron_caseta/test_light.py | 6 ++-- .../components/lutron_caseta/test_logbook.py | 17 +++++----- tests/components/lutron_caseta/test_switch.py | 6 ++-- 34 files changed, 141 insertions(+), 111 deletions(-) diff --git a/tests/components/kaleidescape/test_init.py b/tests/components/kaleidescape/test_init.py index d0826f4714a..28d90290996 100644 --- a/tests/components/kaleidescape/test_init.py +++ b/tests/components/kaleidescape/test_init.py @@ -47,11 +47,11 @@ async def test_config_entry_not_ready( async def test_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_device: AsyncMock, mock_integration: MockConfigEntry, ) -> None: """Test device.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={("kaleidescape", MOCK_SERIAL)} ) diff --git a/tests/components/kaleidescape/test_media_player.py b/tests/components/kaleidescape/test_media_player.py index f38c61d3e73..ad7dcbcaa51 100644 --- a/tests/components/kaleidescape/test_media_player.py +++ b/tests/components/kaleidescape/test_media_player.py @@ -170,11 +170,11 @@ async def test_services( async def test_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_device: MagicMock, mock_integration: MockConfigEntry, ) -> None: """Test device attributes.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={("kaleidescape", MOCK_SERIAL)} ) diff --git a/tests/components/kaleidescape/test_sensor.py b/tests/components/kaleidescape/test_sensor.py index 3fbff29e3e9..70406872464 100644 --- a/tests/components/kaleidescape/test_sensor.py +++ b/tests/components/kaleidescape/test_sensor.py @@ -18,12 +18,13 @@ FRIENDLY_NAME = f"Kaleidescape Device {MOCK_SERIAL}" async def test_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_device: MagicMock, mock_integration: MockConfigEntry, ) -> None: """Test sensors.""" entity = hass.states.get(f"{ENTITY_ID}_media_location") - entry = er.async_get(hass).async_get(f"{ENTITY_ID}_media_location") + entry = entity_registry.async_get(f"{ENTITY_ID}_media_location") assert entity assert entity.state == "none" assert ( @@ -33,7 +34,7 @@ async def test_sensors( assert entry.unique_id == f"{MOCK_SERIAL}-media_location" entity = hass.states.get(f"{ENTITY_ID}_play_status") - entry = er.async_get(hass).async_get(f"{ENTITY_ID}_play_status") + entry = entity_registry.async_get(f"{ENTITY_ID}_play_status") assert entity assert entity.state == "none" assert entity.attributes.get(ATTR_FRIENDLY_NAME) == f"{FRIENDLY_NAME} Play status" diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 47715433a52..aace7a0224c 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -24,7 +24,7 @@ from tests.common import ( async def test_binary_sensor_entity_category( - hass: HomeAssistant, knx: KNXTestKit + hass: HomeAssistant, entity_registry: er.EntityRegistry, knx: KNXTestKit ) -> None: """Test KNX binary sensor entity category.""" await knx.setup_integration( @@ -42,8 +42,7 @@ async def test_binary_sensor_entity_category( await knx.assert_read("1/1/1") await knx.receive_response("1/1/1", True) - registry = er.async_get(hass) - entity = registry.async_get("binary_sensor.test_normal") + entity = entity_registry.async_get("binary_sensor.test_normal") assert entity.entity_category is EntityCategory.DIAGNOSTIC diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 5ef913ab74b..3ba351a4225 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -134,7 +134,9 @@ async def test_sensor( async def test_sensors_available_after_restart( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test that all sensors are added again after a restart.""" with patch( @@ -153,7 +155,6 @@ async def test_sensors_available_after_restart( ) entry.add_to_hass(hass) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "XBT_USD")}, diff --git a/tests/components/lametric/test_helpers.py b/tests/components/lametric/test_helpers.py index 9a03a4d52cf..a1b824086d2 100644 --- a/tests/components/lametric/test_helpers.py +++ b/tests/components/lametric/test_helpers.py @@ -12,12 +12,11 @@ from tests.common import MockConfigEntry async def test_get_coordinator_by_device_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test get LaMetric coordinator by device ID .""" - entity_registry = er.async_get(hass) - with pytest.raises(ValueError, match="Unknown LaMetric device ID: bla"): async_get_coordinator_by_device_id(hass, "bla") diff --git a/tests/components/lametric/test_services.py b/tests/components/lametric/test_services.py index 6a6ff4256a7..9a1258a82bb 100644 --- a/tests/components/lametric/test_services.py +++ b/tests/components/lametric/test_services.py @@ -34,10 +34,10 @@ pytestmark = pytest.mark.usefixtures("init_integration") async def test_service_chart( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric chart service.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get("button.frenck_s_lametric_next_app") assert entry @@ -121,10 +121,10 @@ async def test_service_chart( async def test_service_message( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric message service.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get("button.frenck_s_lametric_next_app") assert entry diff --git a/tests/components/landisgyr_heat_meter/test_init.py b/tests/components/landisgyr_heat_meter/test_init.py index 46fc07c5eb9..f8615aa77af 100644 --- a/tests/components/landisgyr_heat_meter/test_init.py +++ b/tests/components/landisgyr_heat_meter/test_init.py @@ -39,7 +39,9 @@ async def test_unload_entry(_, hass: HomeAssistant) -> None: @patch(API_HEAT_METER_SERVICE) -async def test_migrate_entry(_, hass: HomeAssistant) -> None: +async def test_migrate_entry( + _, hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test successful migration of entry data from version 1 to 2.""" mock_entry_data = { @@ -59,8 +61,7 @@ async def test_migrate_entry(_, hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) # Create entity entry to migrate to new unique ID - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, LANDISGYR_HEAT_METER_DOMAIN, "landisgyr_heat_meter_987654321_measuring_range_m3ph", @@ -74,5 +75,5 @@ async def test_migrate_entry(_, hass: HomeAssistant) -> None: # Check if entity unique id is migrated successfully assert mock_entry.version == 2 - entity = registry.async_get("sensor.heat_meter_measuring_range") + entity = entity_registry.async_get("sensor.heat_meter_measuring_range") assert entity.unique_id == "12345_measuring_range_m3ph" diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 70df5af2305..c92a45d7cc9 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -37,9 +37,10 @@ async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: assert state -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) entity_setpoint1 = entity_registry.async_get(BINARY_SENSOR_LOCKREGULATOR1) assert entity_setpoint1 diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index 74240c900be..4705591e1d3 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -38,9 +38,10 @@ async def test_setup_lcn_cover(hass: HomeAssistant, entry, lcn_connection) -> No assert state.state == STATE_OPEN -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) entity_outputs = entity_registry.async_get(COVER_OUTPUTS) diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 47287fbd1d2..59cabb309b0 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -49,12 +49,11 @@ async def test_get_triggers_module_device( async def test_get_triggers_non_module_device( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, device_registry: dr.DeviceRegistry, entry, lcn_connection ) -> None: """Test we get the expected triggers from a LCN non-module device.""" not_included_types = ("transmitter", "transponder", "fingerprint", "send_keys") - device_registry = dr.async_get(hass) host_device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index a3b5b01ffbb..fb1d09d91d6 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -48,20 +48,23 @@ async def test_async_setup_multiple_entries(hass: HomeAssistant, entry, entry2) assert not hass.data.get(DOMAIN) -async def test_async_setup_entry_update(hass: HomeAssistant, entry) -> None: +async def test_async_setup_entry_update( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entry, +) -> None: """Test a successful setup entry if entry with same id already exists.""" # setup first entry entry.source = config_entries.SOURCE_IMPORT entry.add_to_hass(hass) # create dummy entity for LCN platform as an orphan - entity_registry = er.async_get(hass) dummy_entity = entity_registry.async_get_or_create( "switch", DOMAIN, "dummy", config_entry=entry ) # create dummy device for LCN platform as an orphan - device_registry = dr.async_get(hass) dummy_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id, 0, 7, False)}, diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index 73827ad38bb..7f23c1e6214 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -58,10 +58,10 @@ async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) - entity_output = entity_registry.async_get(LIGHT_OUTPUT1) assert entity_output diff --git a/tests/components/lcn/test_sensor.py b/tests/components/lcn/test_sensor.py index 116ab62854d..b46de397255 100644 --- a/tests/components/lcn/test_sensor.py +++ b/tests/components/lcn/test_sensor.py @@ -49,9 +49,10 @@ async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: assert state -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) entity_var1 = entity_registry.async_get(SENSOR_VAR1) assert entity_var1 diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 44a9e410fe3..a83d45c0889 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -39,9 +39,10 @@ async def test_setup_lcn_switch(hass: HomeAssistant, lcn_connection) -> None: assert state.state == STATE_OFF -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) entity_output = entity_registry.async_get(SWITCH_OUTPUT1) diff --git a/tests/components/lidarr/test_init.py b/tests/components/lidarr/test_init.py index 5d6961e57c3..ce3a8536b2f 100644 --- a/tests/components/lidarr/test_init.py +++ b/tests/components/lidarr/test_init.py @@ -45,12 +45,14 @@ async def test_async_setup_entry_auth_failed( async def test_device_info( - hass: HomeAssistant, setup_integration: ComponentSetup, connection + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, + connection, ) -> None: """Test device info.""" await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - device_registry = dr.async_get(hass) await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/lifx/test_binary_sensor.py b/tests/components/lifx/test_binary_sensor.py index d71a7eeaf0b..9fa065f3632 100644 --- a/tests/components/lifx/test_binary_sensor.py +++ b/tests/components/lifx/test_binary_sensor.py @@ -31,7 +31,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_hev_cycle_state(hass: HomeAssistant) -> None: +async def test_hev_cycle_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test HEV cycle state binary sensor.""" config_entry = MockConfigEntry( domain=lifx.DOMAIN, @@ -48,7 +50,6 @@ async def test_hev_cycle_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "binary_sensor.my_bulb_clean_cycle" - entity_registry = er.async_get(hass) state = hass.states.get(entity_id) assert state diff --git a/tests/components/lifx/test_button.py b/tests/components/lifx/test_button.py index d527229fe78..1fd4da4531e 100644 --- a/tests/components/lifx/test_button.py +++ b/tests/components/lifx/test_button.py @@ -31,7 +31,9 @@ def mock_lifx_coordinator_sleep(): yield -async def test_button_restart(hass: HomeAssistant) -> None: +async def test_button_restart( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that a bulb can be restarted.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -50,7 +52,6 @@ async def test_button_restart(hass: HomeAssistant) -> None: unique_id = f"{SERIAL}_restart" entity_id = "button.my_bulb_restart" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled @@ -63,7 +64,9 @@ async def test_button_restart(hass: HomeAssistant) -> None: bulb.set_reboot.assert_called_once() -async def test_button_identify(hass: HomeAssistant) -> None: +async def test_button_identify( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that a bulb can be identified.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -82,7 +85,6 @@ async def test_button_identify(hass: HomeAssistant) -> None: unique_id = f"{SERIAL}_identify" entity_id = "button.my_bulb_identify" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index 1b7da4f864a..70284106166 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -536,7 +536,11 @@ async def test_refuse_relays(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_suggested_area(hass: HomeAssistant) -> None: +async def test_suggested_area( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test suggested area is populated from lifx group label.""" class MockLifxCommandGetGroup: @@ -567,10 +571,8 @@ async def test_suggested_area(hass: HomeAssistant) -> None: await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) entity_id = "light.my_bulb" entity = entity_registry.async_get(entity_id) - device_registry = dr.async_get(hass) device = device_registry.async_get(entity.device_id) assert device.suggested_area == "My LIFX Group" diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 70a5a89a3ae..887e622b5cc 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -81,7 +81,11 @@ def patch_lifx_state_settle_delay(): yield -async def test_light_unique_id(hass: HomeAssistant) -> None: +async def test_light_unique_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test a light unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL @@ -95,17 +99,19 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.my_bulb" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL - device_registry = dr.async_get(hass) device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, SERIAL)} ) assert device.identifiers == {(DOMAIN, SERIAL)} -async def test_light_unique_id_new_firmware(hass: HomeAssistant) -> None: +async def test_light_unique_id_new_firmware( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test a light unique id with newer firmware.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL @@ -119,9 +125,7 @@ async def test_light_unique_id_new_firmware(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.my_bulb" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL - device_registry = dr.async_get(hass) device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, ) @@ -1115,7 +1119,9 @@ async def test_white_bulb(hass: HomeAssistant) -> None: bulb.set_color.reset_mock() -async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: +async def test_config_zoned_light_strip_fails( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we handle failure to update zones.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL @@ -1144,7 +1150,6 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: with _patch_discovery(device=light_strip), _patch_device(device=light_strip): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL assert hass.states.get(entity_id).state == STATE_OFF @@ -1153,7 +1158,9 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == STATE_UNAVAILABLE -async def test_legacy_zoned_light_strip(hass: HomeAssistant) -> None: +async def test_legacy_zoned_light_strip( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we handle failure to update zones.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL @@ -1183,7 +1190,6 @@ async def test_legacy_zoned_light_strip(hass: HomeAssistant) -> None: with _patch_discovery(device=light_strip), _patch_device(device=light_strip): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL assert hass.states.get(entity_id).state == STATE_OFF # 1 to get the number of zones @@ -1197,7 +1203,9 @@ async def test_legacy_zoned_light_strip(hass: HomeAssistant) -> None: assert get_color_zones_mock.call_count == 5 -async def test_white_light_fails(hass: HomeAssistant) -> None: +async def test_white_light_fails( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we handle failure to power on off.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL @@ -1211,7 +1219,6 @@ async def test_white_light_fails(hass: HomeAssistant) -> None: with _patch_discovery(device=bulb), _patch_device(device=bulb): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL assert hass.states.get(entity_id).state == STATE_OFF with pytest.raises(HomeAssistantError): diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py index aa705418d55..529925be726 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -25,7 +25,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_theme_select(hass: HomeAssistant) -> None: +async def test_theme_select( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test selecting a theme.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -46,7 +48,6 @@ async def test_theme_select(hass: HomeAssistant) -> None: entity_id = "select.my_bulb_theme" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled @@ -62,7 +63,9 @@ async def test_theme_select(hass: HomeAssistant) -> None: bulb.set_extended_color_zones.reset_mock() -async def test_infrared_brightness(hass: HomeAssistant) -> None: +async def test_infrared_brightness( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test getting and setting infrared brightness.""" config_entry = MockConfigEntry( @@ -82,7 +85,6 @@ async def test_infrared_brightness(hass: HomeAssistant) -> None: unique_id = f"{SERIAL}_infrared_brightness" entity_id = "select.my_bulb_infrared_brightness" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py index 5fe69c8dabc..e27bc0de3a8 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -31,7 +31,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_rssi_sensor(hass: HomeAssistant) -> None: +async def test_rssi_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test LIFX RSSI sensor entity.""" config_entry = MockConfigEntry( @@ -49,7 +51,6 @@ async def test_rssi_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "sensor.my_bulb_rssi" - entity_registry = er.async_get(hass) entry = entity_registry.entities.get(entity_id) assert entry @@ -82,7 +83,9 @@ async def test_rssi_sensor(hass: HomeAssistant) -> None: assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT -async def test_rssi_sensor_old_firmware(hass: HomeAssistant) -> None: +async def test_rssi_sensor_old_firmware( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test LIFX RSSI sensor entity.""" config_entry = MockConfigEntry( @@ -100,7 +103,6 @@ async def test_rssi_sensor_old_firmware(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "sensor.my_bulb_rssi" - entity_registry = er.async_get(hass) entry = entity_registry.entities.get(entity_id) assert entry diff --git a/tests/components/litejet/test_scene.py b/tests/components/litejet/test_scene.py index d1316d81bbe..76c1556f66d 100644 --- a/tests/components/litejet/test_scene.py +++ b/tests/components/litejet/test_scene.py @@ -17,16 +17,16 @@ ENTITY_OTHER_SCENE = "scene.litejet_mock_scene_2" ENTITY_OTHER_SCENE_NUMBER = 2 -async def test_disabled_by_default(hass: HomeAssistant, mock_litejet) -> None: +async def test_disabled_by_default( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_litejet +) -> None: """Test the scene is disabled by default.""" await async_init_integration(hass) - registry = er.async_get(hass) - state = hass.states.get(ENTITY_SCENE) assert state is None - entry = registry.async_get(ENTITY_SCENE) + entry = entity_registry.async_get(ENTITY_SCENE) assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/litterrobot/test_button.py b/tests/components/litterrobot/test_button.py index a17c0439824..9a4145dd224 100644 --- a/tests/components/litterrobot/test_button.py +++ b/tests/components/litterrobot/test_button.py @@ -13,10 +13,11 @@ from .conftest import setup_integration BUTTON_ENTITY = "button.test_reset_waste_drawer" -async def test_button(hass: HomeAssistant, mock_account: MagicMock) -> None: +async def test_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock +) -> None: """Test the creation and values of the Litter-Robot button.""" await setup_integration(hass, mock_account, BUTTON_DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get(BUTTON_ENTITY) assert state diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 170d6313029..25c47ee4945 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .common import CONFIG, VACUUM_ENTITY_ID, remove_device @@ -73,17 +72,19 @@ async def test_entry_not_setup( async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_account: MagicMock + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_account: MagicMock, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) config_entry = await setup_integration(hass, mock_account, VACUUM_DOMAIN) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities[VACUUM_ENTITY_ID] + entity = entity_registry.entities[VACUUM_ENTITY_ID] assert entity.unique_id == "LR3C012345-litter_box" - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) assert ( await remove_device( diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 3aee7b5075f..fe77119ca5e 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -32,21 +32,22 @@ COMPONENT_SERVICE_DOMAIN = { } -async def test_vacuum(hass: HomeAssistant, mock_account: MagicMock) -> None: +async def test_vacuum( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock +) -> None: """Tests the vacuum entity was set up.""" - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( PLATFORM_DOMAIN, DOMAIN, VACUUM_UNIQUE_ID, suggested_object_id=VACUUM_ENTITY_ID.replace(PLATFORM_DOMAIN, ""), ) - ent_reg_entry = ent_reg.async_get(VACUUM_ENTITY_ID) + ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID await setup_integration(hass, mock_account, PLATFORM_DOMAIN) - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) vacuum = hass.states.get(VACUUM_ENTITY_ID) @@ -54,7 +55,7 @@ async def test_vacuum(hass: HomeAssistant, mock_account: MagicMock) -> None: assert vacuum.state == STATE_DOCKED assert vacuum.attributes["is_sleeping"] is False - ent_reg_entry = ent_reg.async_get(VACUUM_ENTITY_ID) + ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID @@ -70,15 +71,16 @@ async def test_vacuum_status_when_sleeping( async def test_no_robots( - hass: HomeAssistant, mock_account_with_no_robots: MagicMock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_account_with_no_robots: MagicMock, ) -> None: """Tests the vacuum entity was set up.""" entry = await setup_integration(hass, mock_account_with_no_robots, PLATFORM_DOMAIN) assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 0 + assert len(entity_registry.entities) == 0 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 24b13d48a1e..31ad8fc60ac 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -103,10 +103,10 @@ async def test_lock_states(hass: HomeAssistant) -> None: async def test_set_default_code_option( hass: HomeAssistant, + entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test default code stored in the registry.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get_or_create("lock", "test", "very_unique") await hass.async_block_till_done() @@ -134,10 +134,10 @@ async def test_set_default_code_option( async def test_default_code_option_update( hass: HomeAssistant, + entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test default code stored in the registry is updated.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get_or_create("lock", "test", "very_unique") await hass.async_block_till_done() diff --git a/tests/components/luftdaten/test_sensor.py b/tests/components/luftdaten/test_sensor.py index e9e86fd9f1b..7a2cac1721b 100644 --- a/tests/components/luftdaten/test_sensor.py +++ b/tests/components/luftdaten/test_sensor.py @@ -23,11 +23,11 @@ from tests.common import MockConfigEntry async def test_luftdaten_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the Luftdaten sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) entry = entity_registry.async_get("sensor.sensor_12345_temperature") assert entry diff --git a/tests/components/lutron_caseta/test_button.py b/tests/components/lutron_caseta/test_button.py index 68742e5bae3..378db23715c 100644 --- a/tests/components/lutron_caseta/test_button.py +++ b/tests/components/lutron_caseta/test_button.py @@ -8,7 +8,9 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_button_unique_id(hass: HomeAssistant) -> None: +async def test_button_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a button unique id.""" await async_setup_integration(hass, MockBridge) @@ -17,8 +19,6 @@ async def test_button_unique_id(hass: HomeAssistant) -> None: ) caseta_button_entity_id = "button.dining_room_pico_stop" - entity_registry = er.async_get(hass) - # Assert that Caseta buttons will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(ra3_button_entity_id).unique_id == "000004d2_1372" assert ( diff --git a/tests/components/lutron_caseta/test_cover.py b/tests/components/lutron_caseta/test_cover.py index ef5fc2a5228..7fe8ed22866 100644 --- a/tests/components/lutron_caseta/test_cover.py +++ b/tests/components/lutron_caseta/test_cover.py @@ -7,13 +7,13 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_cover_unique_id(hass: HomeAssistant) -> None: +async def test_cover_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass, MockBridge) cover_entity_id = "cover.basement_bedroom_left_shade" - entity_registry = er.async_get(hass) - # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(cover_entity_id).unique_id == "000004d2_802" diff --git a/tests/components/lutron_caseta/test_fan.py b/tests/components/lutron_caseta/test_fan.py index f9c86cc9c58..0147817514d 100644 --- a/tests/components/lutron_caseta/test_fan.py +++ b/tests/components/lutron_caseta/test_fan.py @@ -7,13 +7,13 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_fan_unique_id(hass: HomeAssistant) -> None: +async def test_fan_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass, MockBridge) fan_entity_id = "fan.master_bedroom_ceiling_fan" - entity_registry = er.async_get(hass) - # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(fan_entity_id).unique_id == "000004d2_804" diff --git a/tests/components/lutron_caseta/test_light.py b/tests/components/lutron_caseta/test_light.py index 6449ce04832..cdba9a956e5 100644 --- a/tests/components/lutron_caseta/test_light.py +++ b/tests/components/lutron_caseta/test_light.py @@ -8,15 +8,15 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_light_unique_id(hass: HomeAssistant) -> None: +async def test_light_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass, MockBridge) ra3_entity_id = "light.basement_bedroom_main_lights" caseta_entity_id = "light.kitchen_main_lights" - entity_registry = er.async_get(hass) - # Assert that RA3 lights will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(ra3_entity_id).unique_id == "000004d2_801" diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index 8390370d16d..c0bac43ba6f 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -82,7 +82,7 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None: async def test_humanify_lutron_caseta_button_event_integration_not_loaded( - hass: HomeAssistant, + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test humanifying lutron_caseta_button_events when the integration fails to load.""" hass.config.components.add("recorder") @@ -109,7 +109,6 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - device_registry = dr.async_get(hass) for device in device_registry.devices.values(): if device.config_entries == {config_entry.entry_id}: dr_device_id = device.id @@ -140,14 +139,15 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( assert event1["message"] == "press stop" -async def test_humanify_lutron_caseta_button_event_ra3(hass: HomeAssistant) -> None: +async def test_humanify_lutron_caseta_button_event_ra3( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test humanifying lutron_caseta_button_events from an RA3 hub.""" hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) await async_setup_integration(hass, MockBridge) - registry = dr.async_get(hass) - keypad = registry.async_get_device( + keypad = device_registry.async_get_device( identifiers={(DOMAIN, 66286451)}, connections=set() ) assert keypad @@ -176,14 +176,15 @@ async def test_humanify_lutron_caseta_button_event_ra3(hass: HomeAssistant) -> N assert event1["message"] == "press Kitchen Pendants" -async def test_humanify_lutron_caseta_button_unknown_type(hass: HomeAssistant) -> None: +async def test_humanify_lutron_caseta_button_unknown_type( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test humanifying lutron_caseta_button_events with an unknown type.""" hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) await async_setup_integration(hass, MockBridge) - registry = dr.async_get(hass) - keypad = registry.async_get_device( + keypad = device_registry.async_get_device( identifiers={(DOMAIN, 66286451)}, connections=set() ) assert keypad diff --git a/tests/components/lutron_caseta/test_switch.py b/tests/components/lutron_caseta/test_switch.py index 842aca94423..c38305ec26b 100644 --- a/tests/components/lutron_caseta/test_switch.py +++ b/tests/components/lutron_caseta/test_switch.py @@ -6,13 +6,13 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_switch_unique_id(hass: HomeAssistant) -> None: +async def test_switch_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass, MockBridge) switch_entity_id = "switch.basement_bathroom_exhaust_fan" - entity_registry = er.async_get(hass) - # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(switch_entity_id).unique_id == "000004d2_803" From 2557e41ec0191f4550e882065a9a9b21e6c64abd Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Mon, 13 Nov 2023 19:10:15 +0000 Subject: [PATCH 451/982] Fix Coinbase for new API Structure (#103930) --- .../components/coinbase/config_flow.py | 3 +- homeassistant/components/coinbase/const.py | 4 +- homeassistant/components/coinbase/sensor.py | 42 ++++++++++--------- tests/components/coinbase/common.py | 13 +++++- tests/components/coinbase/const.py | 9 ++-- .../coinbase/snapshots/test_diagnostics.ambr | 24 ++++------- 6 files changed, 50 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 5dc60f535d7..38053295411 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -17,6 +17,7 @@ import homeassistant.helpers.config_validation as cv from . import get_accounts from .const import ( API_ACCOUNT_CURRENCY, + API_ACCOUNT_CURRENCY_CODE, API_RATES, API_RESOURCE_TYPE, API_TYPE_VAULT, @@ -81,7 +82,7 @@ async def validate_options( accounts = await hass.async_add_executor_job(get_accounts, client) accounts_currencies = [ - account[API_ACCOUNT_CURRENCY] + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] for account in accounts if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index c5fdec4d511..3fc8158f970 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -12,14 +12,16 @@ DOMAIN = "coinbase" API_ACCOUNT_AMOUNT = "amount" API_ACCOUNT_BALANCE = "balance" API_ACCOUNT_CURRENCY = "currency" +API_ACCOUNT_CURRENCY_CODE = "code" API_ACCOUNT_ID = "id" -API_ACCOUNT_NATIVE_BALANCE = "native_balance" +API_ACCOUNT_NATIVE_BALANCE = "balance" API_ACCOUNT_NAME = "name" API_ACCOUNTS_DATA = "data" API_RATES = "rates" API_RESOURCE_PATH = "resource_path" API_RESOURCE_TYPE = "type" API_TYPE_VAULT = "vault" +API_USD = "USD" WALLETS = { "1INCH": "1INCH", diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 47fd3b91129..1442a626f74 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -14,9 +14,9 @@ from .const import ( API_ACCOUNT_AMOUNT, API_ACCOUNT_BALANCE, API_ACCOUNT_CURRENCY, + API_ACCOUNT_CURRENCY_CODE, API_ACCOUNT_ID, API_ACCOUNT_NAME, - API_ACCOUNT_NATIVE_BALANCE, API_RATES, API_RESOURCE_TYPE, API_TYPE_VAULT, @@ -55,7 +55,7 @@ async def async_setup_entry( entities: list[SensorEntity] = [] provided_currencies: list[str] = [ - account[API_ACCOUNT_CURRENCY] + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] for account in instance.accounts if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] @@ -106,26 +106,28 @@ class AccountSensor(SensorEntity): self._currency = currency for account in coinbase_data.accounts: if ( - account[API_ACCOUNT_CURRENCY] != currency + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency or account[API_RESOURCE_TYPE] == API_TYPE_VAULT ): continue self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}" self._attr_unique_id = ( f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" - f"{account[API_ACCOUNT_CURRENCY]}" + f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}" ) self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] - self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY] + self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][ + API_ACCOUNT_CURRENCY_CODE + ] self._attr_icon = CURRENCY_ICONS.get( - account[API_ACCOUNT_CURRENCY], DEFAULT_COIN_ICON + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE], + DEFAULT_COIN_ICON, + ) + self._native_balance = round( + float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]) + / float(coinbase_data.exchange_rates[API_RATES][currency]), + 2, ) - self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_AMOUNT - ] - self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_CURRENCY - ] break self._attr_state_class = SensorStateClass.TOTAL @@ -141,7 +143,7 @@ class AccountSensor(SensorEntity): def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the sensor.""" return { - ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}", + ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", } def update(self) -> None: @@ -149,17 +151,17 @@ class AccountSensor(SensorEntity): self._coinbase_data.update() for account in self._coinbase_data.accounts: if ( - account[API_ACCOUNT_CURRENCY] != self._currency + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] + != self._currency or account[API_RESOURCE_TYPE] == API_TYPE_VAULT ): continue self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] - self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_AMOUNT - ] - self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_CURRENCY - ] + self._native_balance = round( + float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]) + / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]), + 2, + ) break diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 6ab33f3bc7c..0f8930dbeff 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -6,7 +6,12 @@ from homeassistant.components.coinbase.const import ( ) from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN -from .const import GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2, MOCK_ACCOUNTS_RESPONSE +from .const import ( + GOOD_CURRENCY_2, + GOOD_EXCHANGE_RATE, + GOOD_EXCHANGE_RATE_2, + MOCK_ACCOUNTS_RESPONSE, +) from tests.common import MockConfigEntry @@ -60,7 +65,11 @@ def mock_get_exchange_rates(): """Return a heavily reduced mock list of exchange rates for testing.""" return { "currency": "USD", - "rates": {GOOD_EXCHANGE_RATE_2: "0.109", GOOD_EXCHANGE_RATE: "0.00002"}, + "rates": { + GOOD_CURRENCY_2: "1.0", + GOOD_EXCHANGE_RATE_2: "0.109", + GOOD_EXCHANGE_RATE: "0.00002", + }, } diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 2b437e15478..138b941c62c 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -12,26 +12,23 @@ BAD_EXCHANGE_RATE = "ETH" MOCK_ACCOUNTS_RESPONSE = [ { "balance": {"amount": "0.00001", "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, + "currency": {"code": GOOD_CURRENCY}, "id": "123456789", "name": "BTC Wallet", - "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, "type": "wallet", }, { "balance": {"amount": "100.00", "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, + "currency": {"code": GOOD_CURRENCY}, "id": "abcdefg", "name": "BTC Vault", - "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, "type": "vault", }, { "balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, - "currency": "USD", + "currency": {"code": GOOD_CURRENCY_2}, "id": "987654321", "name": "USD Wallet", - "native_balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, "type": "fiat", }, ] diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index c214330d5f9..38224a9992f 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -7,13 +7,11 @@ 'amount': '**REDACTED**', 'currency': 'BTC', }), - 'currency': 'BTC', + 'currency': dict({ + 'code': 'BTC', + }), 'id': '**REDACTED**', 'name': 'BTC Wallet', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'wallet', }), dict({ @@ -21,13 +19,11 @@ 'amount': '**REDACTED**', 'currency': 'BTC', }), - 'currency': 'BTC', + 'currency': dict({ + 'code': 'BTC', + }), 'id': '**REDACTED**', 'name': 'BTC Vault', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'vault', }), dict({ @@ -35,13 +31,11 @@ 'amount': '**REDACTED**', 'currency': 'USD', }), - 'currency': 'USD', + 'currency': dict({ + 'code': 'USD', + }), 'id': '**REDACTED**', 'name': 'USD Wallet', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'fiat', }), ]), From 685537e475f256c724381bb12a378cec82264c04 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 13 Nov 2023 20:48:33 +0100 Subject: [PATCH 452/982] Exchange co2signal package with aioelectricitymaps (#101955) --- .coveragerc | 1 + .../components/co2signal/__init__.py | 10 ++- .../components/co2signal/config_flow.py | 32 +++---- .../components/co2signal/coordinator.py | 88 +++++-------------- .../components/co2signal/diagnostics.py | 3 +- .../components/co2signal/exceptions.py | 18 ---- homeassistant/components/co2signal/helpers.py | 28 ++++++ .../components/co2signal/manifest.json | 4 +- homeassistant/components/co2signal/models.py | 24 ----- homeassistant/components/co2signal/sensor.py | 47 +++++----- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- .../co2signal/snapshots/test_diagnostics.ambr | 8 +- .../components/co2signal/test_config_flow.py | 37 ++++---- .../components/co2signal/test_diagnostics.py | 5 +- 15 files changed, 135 insertions(+), 182 deletions(-) delete mode 100644 homeassistant/components/co2signal/exceptions.py create mode 100644 homeassistant/components/co2signal/helpers.py delete mode 100644 homeassistant/components/co2signal/models.py diff --git a/.coveragerc b/.coveragerc index 7bec02cc47f..04c9182e0df 100644 --- a/.coveragerc +++ b/.coveragerc @@ -187,6 +187,7 @@ omit = homeassistant/components/control4/director_utils.py homeassistant/components/control4/light.py homeassistant/components/coolmaster/coordinator.py + homeassistant/components/co2signal/coordinator.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/crownstone/__init__.py homeassistant/components/crownstone/devices.py diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 04ae811197b..028d37a73c5 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -1,9 +1,12 @@ """The CO2 Signal integration.""" from __future__ import annotations +from aioelectricitymaps import ElectricityMaps + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import CO2SignalCoordinator @@ -13,7 +16,10 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up CO2 Signal from a config entry.""" - coordinator = CO2SignalCoordinator(hass, entry) + session = async_get_clientsession(hass) + coordinator = CO2SignalCoordinator( + hass, ElectricityMaps(token=entry.data[CONF_API_KEY], session=session) + ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index d41bd6e0f78..85f437581ac 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -3,11 +3,14 @@ from __future__ import annotations from typing import Any +from aioelectricitymaps import ElectricityMaps +from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, @@ -16,8 +19,7 @@ from homeassistant.helpers.selector import ( ) from .const import CONF_COUNTRY_CODE, DOMAIN -from .coordinator import get_data -from .exceptions import APIRatelimitExceeded, InvalidAuth +from .helpers import fetch_latest_carbon_intensity from .util import get_extra_name TYPE_USE_HOME = "use_home_location" @@ -117,19 +119,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Validate data and show form if it is invalid.""" errors: dict[str, str] = {} - try: - await self.hass.async_add_executor_job(get_data, self.hass, data) - except InvalidAuth: - errors["base"] = "invalid_auth" - except APIRatelimitExceeded: - errors["base"] = "api_ratelimit" - except Exception: # pylint: disable=broad-except - errors["base"] = "unknown" - else: - return self.async_create_entry( - title=get_extra_name(data) or "CO2 Signal", - data=data, - ) + session = async_get_clientsession(self.hass) + async with ElectricityMaps(token=data[CONF_API_KEY], session=session) as em: + try: + await fetch_latest_carbon_intensity(self.hass, em, data) + except InvalidToken: + errors["base"] = "invalid_auth" + except ElectricityMapsError: + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=get_extra_name(data) or "CO2 Signal", + data=data, + ) return self.async_show_form( step_id=step_id, diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 24d7bbd18af..1f4abf278c0 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -1,94 +1,50 @@ """DataUpdateCoordinator for the co2signal integration.""" from __future__ import annotations -from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any, cast -import CO2Signal -from requests.exceptions import JSONDecodeError +from aioelectricitymaps import ElectricityMaps +from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken +from aioelectricitymaps.models import CarbonIntensityResponse from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_COUNTRY_CODE, DOMAIN -from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError -from .models import CO2SignalResponse +from .const import DOMAIN +from .helpers import fetch_latest_carbon_intensity _LOGGER = logging.getLogger(__name__) -class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): +class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): """Data update coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, client: ElectricityMaps) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) ) - self._entry = entry + self.client = client @property def entry_id(self) -> str: """Return entry ID.""" - return self._entry.entry_id + return self.config_entry.entry_id - async def _async_update_data(self) -> CO2SignalResponse: + async def _async_update_data(self) -> CarbonIntensityResponse: """Fetch the latest data from the source.""" - try: - data = await self.hass.async_add_executor_job( - get_data, self.hass, self._entry.data - ) - except InvalidAuth as err: - raise ConfigEntryAuthFailed from err - except CO2Error as err: - raise UpdateFailed(str(err)) from err - return data - - -def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: - """Get data from the API.""" - if CONF_COUNTRY_CODE in config: - latitude = None - longitude = None - else: - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - try: - data = CO2Signal.get_latest( - config[CONF_API_KEY], - config.get(CONF_COUNTRY_CODE), - latitude, - longitude, - wait=False, - ) - - except JSONDecodeError as err: - # raise occasional occurring json decoding errors as CO2Error so the data update coordinator retries it - raise CO2Error from err - - except ValueError as err: - err_str = str(err) - - if "Invalid authentication credentials" in err_str: - raise InvalidAuth from err - if "API rate limit exceeded." in err_str: - raise APIRatelimitExceeded from err - - _LOGGER.exception("Unexpected exception") - raise UnknownError from err - - if "error" in data: - raise UnknownError(data["error"]) - - if data.get("status") != "ok": - _LOGGER.exception("Unexpected response: %s", data) - raise UnknownError - - return cast(CO2SignalResponse, data) + async with self.client as em: + try: + return await fetch_latest_carbon_intensity( + self.hass, em, self.config_entry.data + ) + except InvalidToken as err: + raise ConfigEntryError from err + except ElectricityMapsError as err: + raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/co2signal/diagnostics.py b/homeassistant/components/co2signal/diagnostics.py index db08aa4eca6..1c53f7c5b08 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -1,6 +1,7 @@ """Diagnostics support for CO2Signal.""" from __future__ import annotations +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -22,5 +23,5 @@ async def async_get_config_entry_diagnostics( return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), - "data": coordinator.data, + "data": asdict(coordinator.data), } diff --git a/homeassistant/components/co2signal/exceptions.py b/homeassistant/components/co2signal/exceptions.py deleted file mode 100644 index cc8ee709bde..00000000000 --- a/homeassistant/components/co2signal/exceptions.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Exceptions to the co2signal integration.""" -from homeassistant.exceptions import HomeAssistantError - - -class CO2Error(HomeAssistantError): - """Base error.""" - - -class InvalidAuth(CO2Error): - """Raised when invalid authentication credentials are provided.""" - - -class APIRatelimitExceeded(CO2Error): - """Raised when the API rate limit is exceeded.""" - - -class UnknownError(CO2Error): - """Raised when an unknown error occurs.""" diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py new file mode 100644 index 00000000000..f794a4b0573 --- /dev/null +++ b/homeassistant/components/co2signal/helpers.py @@ -0,0 +1,28 @@ +"""Helper functions for the CO2 Signal integration.""" +from types import MappingProxyType +from typing import Any + +from aioelectricitymaps import ElectricityMaps +from aioelectricitymaps.models import CarbonIntensityResponse + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from .const import CONF_COUNTRY_CODE + + +async def fetch_latest_carbon_intensity( + hass: HomeAssistant, + em: ElectricityMaps, + config: dict[str, Any] | MappingProxyType[str, Any], +) -> CarbonIntensityResponse: + """Fetch the latest carbon intensity based on country code or location coordinates.""" + if CONF_COUNTRY_CODE in config: + return await em.latest_carbon_intensity_by_country_code( + code=config[CONF_COUNTRY_CODE] + ) + + return await em.latest_carbon_intensity_by_coordinates( + lat=config.get(CONF_LATITUDE, hass.config.latitude), + lon=config.get(CONF_LONGITUDE, hass.config.longitude), + ) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index a4d7c55d6da..d82af5b5034 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/co2signal", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["CO2Signal"], - "requirements": ["CO2Signal==0.4.2"] + "loggers": ["aioelectricitymaps"], + "requirements": ["aioelectricitymaps==0.1.5"] } diff --git a/homeassistant/components/co2signal/models.py b/homeassistant/components/co2signal/models.py deleted file mode 100644 index 758bb15c5f0..00000000000 --- a/homeassistant/components/co2signal/models.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Models to the co2signal integration.""" -from typing import TypedDict - - -class CO2SignalData(TypedDict): - """Data field.""" - - carbonIntensity: float - fossilFuelPercentage: float - - -class CO2SignalUnit(TypedDict): - """Unit field.""" - - carbonIntensity: str - - -class CO2SignalResponse(TypedDict): - """API response.""" - - status: str - countryCode: str - data: CO2SignalData - units: CO2SignalUnit diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index d00bdf70d3e..6f0053d3be4 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -1,9 +1,11 @@ """Support for the CO2signal platform.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from typing import cast + +from aioelectricitymaps.models import CarbonIntensityResponse from homeassistant.components.sensor import ( SensorEntity, @@ -24,11 +26,21 @@ SCAN_INTERVAL = timedelta(minutes=3) @dataclass -class CO2SensorEntityDescription(SensorEntityDescription): +class ElectricityMapsMixin: + """Mixin for value and unit_of_measurement_fn function.""" + + value_fn: Callable[[CarbonIntensityResponse], float | None] + + +@dataclass +class CO2SensorEntityDescription(SensorEntityDescription, ElectricityMapsMixin): """Provide a description of a CO2 sensor.""" # For backwards compat, allow description to override unique ID key to use unique_id: str | None = None + unit_of_measurement_fn: Callable[ + [CarbonIntensityResponse], str | None + ] | None = None SENSORS = ( @@ -36,12 +48,14 @@ SENSORS = ( key="carbonIntensity", translation_key="carbon_intensity", unique_id="co2intensity", - # No unit, it's extracted from response. + value_fn=lambda response: response.data.carbon_intensity, + unit_of_measurement_fn=lambda response: response.units.carbon_intensity, ), CO2SensorEntityDescription( key="fossilFuelPercentage", translation_key="fossil_fuel_percentage", native_unit_of_measurement=PERCENTAGE, + value_fn=lambda response: response.data.fossil_fuel_percentage, ), ) @@ -51,7 +65,9 @@ async def async_setup_entry( ) -> None: """Set up the CO2signal sensor.""" coordinator: CO2SignalCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities(CO2Sensor(coordinator, description) for description in SENSORS) + async_add_entities( + [CO2Sensor(coordinator, description) for description in SENSORS], False + ) class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): @@ -71,7 +87,7 @@ class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): self.entity_description = description self._attr_extra_state_attributes = { - "country_code": coordinator.data["countryCode"], + "country_code": coordinator.data.country_code, } self._attr_device_info = DeviceInfo( configuration_url="https://www.electricitymaps.com/", @@ -84,26 +100,15 @@ class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): f"{coordinator.entry_id}_{description.unique_id or description.key}" ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and self.entity_description.key in self.coordinator.data["data"] - ) - @property def native_value(self) -> float | None: """Return sensor state.""" - if (value := self.coordinator.data["data"][self.entity_description.key]) is None: # type: ignore[literal-required] - return None - return round(value, 2) + return self.entity_description.value_fn(self.coordinator.data) @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" - if self.entity_description.native_unit_of_measurement: - return self.entity_description.native_unit_of_measurement - return cast( - str, self.coordinator.data["units"].get(self.entity_description.key) - ) + if self.entity_description.unit_of_measurement_fn: + return self.entity_description.unit_of_measurement_fn(self.coordinator.data) + + return self.entity_description.native_unit_of_measurement diff --git a/requirements_all.txt b/requirements_all.txt index 61ea011b1b3..eedbd2b21aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -21,9 +21,6 @@ Ambiclimate==0.2.1 # homeassistant.components.blinksticklight BlinkStick==1.2.0 -# homeassistant.components.co2signal -CO2Signal==0.4.2 - # homeassistant.components.doorbird DoorBirdPy==2.1.0 @@ -235,6 +232,9 @@ aioeagle==1.1.0 # homeassistant.components.ecowitt aioecowitt==2023.5.0 +# homeassistant.components.co2signal +aioelectricitymaps==0.1.5 + # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00613925454..4b9fccb0e7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -18,9 +18,6 @@ Adax-local==0.1.5 # homeassistant.components.ambiclimate Ambiclimate==0.2.1 -# homeassistant.components.co2signal -CO2Signal==0.4.2 - # homeassistant.components.doorbird DoorBirdPy==2.1.0 @@ -214,6 +211,9 @@ aioeagle==1.1.0 # homeassistant.components.ecowitt aioecowitt==2023.5.0 +# homeassistant.components.co2signal +aioelectricitymaps==0.1.5 + # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index ffb35edfbbb..53a0f000f28 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -19,14 +19,14 @@ 'version': 1, }), 'data': dict({ - 'countryCode': 'FR', + 'country_code': 'FR', 'data': dict({ - 'carbonIntensity': 45.98623190095805, - 'fossilFuelPercentage': 5.461182741937103, + 'carbon_intensity': 45.98623190095805, + 'fossil_fuel_percentage': 5.461182741937103, }), 'status': 'ok', 'units': dict({ - 'carbonIntensity': 'gCO2eq/kWh', + 'carbon_intensity': 'gCO2eq/kWh', }), }), }) diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 879293ae959..7d782e6e3bd 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,7 +1,11 @@ """Test the CO2 Signal config flow.""" -from json import JSONDecodeError -from unittest.mock import Mock, patch +from unittest.mock import patch +from aioelectricitymaps.exceptions import ( + ElectricityMapsDecodeError, + ElectricityMapsError, + InvalidToken, +) import pytest from homeassistant import config_entries @@ -22,7 +26,7 @@ async def test_form_home(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "CO2Signal.get_latest", + "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", return_value=VALID_PAYLOAD, ), patch( "homeassistant.components.co2signal.async_setup_entry", @@ -64,7 +68,7 @@ async def test_form_coordinates(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM with patch( - "CO2Signal.get_latest", + "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", return_value=VALID_PAYLOAD, ), patch( "homeassistant.components.co2signal.async_setup_entry", @@ -108,7 +112,7 @@ async def test_form_country(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM with patch( - "CO2Signal.get_latest", + "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", return_value=VALID_PAYLOAD, ), patch( "homeassistant.components.co2signal.async_setup_entry", @@ -135,27 +139,16 @@ async def test_form_country(hass: HomeAssistant) -> None: ("side_effect", "err_code"), [ ( - ValueError("Invalid authentication credentials"), + InvalidToken, "invalid_auth", ), - ( - ValueError("API rate limit exceeded."), - "api_ratelimit", - ), - (ValueError("Something else"), "unknown"), - (JSONDecodeError(msg="boom", doc="", pos=1), "unknown"), - (Exception("Boom"), "unknown"), - (Mock(return_value={"error": "boom"}), "unknown"), - (Mock(return_value={"status": "error"}), "unknown"), + (ElectricityMapsError("Something else"), "unknown"), + (ElectricityMapsDecodeError("Boom"), "unknown"), ], ids=[ "invalid auth", - "rate limit exceeded", - "unknown value error", + "generic error", "json decode error", - "unknown error", - "error in json dict", - "status error", ], ) async def test_form_error_handling(hass: HomeAssistant, side_effect, err_code) -> None: @@ -165,7 +158,7 @@ async def test_form_error_handling(hass: HomeAssistant, side_effect, err_code) - ) with patch( - "CO2Signal.get_latest", + "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", side_effect=side_effect, ): result = await hass.config_entries.flow.async_configure( @@ -180,7 +173,7 @@ async def test_form_error_handling(hass: HomeAssistant, side_effect, err_code) - assert result["errors"] == {"base": err_code} with patch( - "CO2Signal.get_latest", + "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", return_value=VALID_PAYLOAD, ): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/co2signal/test_diagnostics.py b/tests/components/co2signal/test_diagnostics.py index ed73cb960b5..15f0027dbd4 100644 --- a/tests/components/co2signal/test_diagnostics.py +++ b/tests/components/co2signal/test_diagnostics.py @@ -27,7 +27,10 @@ async def test_entry_diagnostics( entry_id="904a74160aa6f335526706bee85dfb83", ) config_entry.add_to_hass(hass) - with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD): + with patch( + "homeassistant.components.co2signal.coordinator.ElectricityMaps._get", + return_value=VALID_PAYLOAD, + ): assert await async_setup_component(hass, DOMAIN, {}) result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) From f7fef14d0613faa56e376f066c8406c9fda9ccfc Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 13 Nov 2023 14:49:07 -0500 Subject: [PATCH 453/982] Add diagnostic platform and tests to Blink (#102650) Co-authored-by: J. Nick Koston --- CODEOWNERS | 4 +- homeassistant/components/blink/diagnostics.py | 33 +++++++ homeassistant/components/blink/manifest.json | 2 +- tests/components/blink/conftest.py | 95 +++++++++++++++++++ .../blink/snapshots/test_diagnostics.ambr | 52 ++++++++++ tests/components/blink/test_diagnostics.py | 33 +++++++ 6 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/blink/diagnostics.py create mode 100644 tests/components/blink/conftest.py create mode 100644 tests/components/blink/snapshots/test_diagnostics.ambr create mode 100644 tests/components/blink/test_diagnostics.py diff --git a/CODEOWNERS b/CODEOWNERS index f6737c2e044..6321847b41a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -151,8 +151,8 @@ build.json @home-assistant/supervisor /homeassistant/components/bizkaibus/ @UgaitzEtxebarria /homeassistant/components/blebox/ @bbx-a @riokuu /tests/components/blebox/ @bbx-a @riokuu -/homeassistant/components/blink/ @fronzbot -/tests/components/blink/ @fronzbot +/homeassistant/components/blink/ @fronzbot @mkmer +/tests/components/blink/ @fronzbot @mkmer /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core diff --git a/homeassistant/components/blink/diagnostics.py b/homeassistant/components/blink/diagnostics.py new file mode 100644 index 00000000000..f69c1721bf1 --- /dev/null +++ b/homeassistant/components/blink/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for Blink.""" +from __future__ import annotations + +from typing import Any + +from blinkpy.blinkpy import Blink + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {"serial", "macaddress", "username", "password", "token"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + api: Blink = hass.data[DOMAIN][config_entry.entry_id].api + + data = { + camera.name: dict(camera.attributes.items()) + for _, camera in api.cameras.items() + } + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "cameras": async_redact_data(data, TO_REDACT), + } diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index bb8fd4a5a51..db3ab91de11 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -1,7 +1,7 @@ { "domain": "blink", "name": "Blink", - "codeowners": ["@fronzbot"], + "codeowners": ["@fronzbot", "@mkmer"], "config_flow": true, "dhcp": [ { diff --git a/tests/components/blink/conftest.py b/tests/components/blink/conftest.py new file mode 100644 index 00000000000..382a1689595 --- /dev/null +++ b/tests/components/blink/conftest.py @@ -0,0 +1,95 @@ +"""Fixtures for the Blink integration tests.""" +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +from uuid import uuid4 + +import blinkpy +import pytest + +from homeassistant.components.blink.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +CAMERA_ATTRIBUTES = { + "name": "Camera 1", + "camera_id": "111111", + "serial": "serail", + "temperature": None, + "temperature_c": 25.1, + "temperature_calibrated": None, + "battery": "ok", + "battery_voltage": None, + "thumbnail": "https://rest-u034.immedia-semi.com/api/v3/media/accounts/111111/networks/222222/lotus/333333/thumbnail/thumbnail.jpg?ts=1698141602&ext=", + "video": None, + "recent_clips": [], + "motion_enabled": True, + "motion_detected": False, + "wifi_strength": None, + "network_id": 222222, + "sync_module": "sync module", + "last_record": None, + "type": "lotus", +} + + +@pytest.fixture +def camera() -> MagicMock: + """Set up a Blink camera fixture.""" + mock_blink_camera = create_autospec(blinkpy.camera.BlinkCamera, instance=True) + mock_blink_camera.sync = AsyncMock(return_value=True) + mock_blink_camera.name = "Camera 1" + mock_blink_camera.camera_id = "111111" + mock_blink_camera.serial = "12345" + mock_blink_camera.motion_enabled = True + mock_blink_camera.temperature = 25.1 + mock_blink_camera.motion_detected = False + mock_blink_camera.wifi_strength = 2.1 + mock_blink_camera.camera_type = "lotus" + mock_blink_camera.attributes = CAMERA_ATTRIBUTES + return mock_blink_camera + + +@pytest.fixture(name="mock_blink_api") +def blink_api_fixture(camera) -> MagicMock: + """Set up Blink API fixture.""" + mock_blink_api = create_autospec(blinkpy.blinkpy.Blink, instance=True) + mock_blink_api.available = True + mock_blink_api.start = AsyncMock(return_value=True) + mock_blink_api.refresh = AsyncMock(return_value=True) + mock_blink_api.sync = MagicMock(return_value=True) + mock_blink_api.cameras = {camera.name: camera} + + with patch("homeassistant.components.blink.Blink") as class_mock: + class_mock.return_value = mock_blink_api + yield mock_blink_api + + +@pytest.fixture(name="mock_blink_auth_api") +def blink_auth_api_fixture(): + """Set up Blink API fixture.""" + with patch( + "homeassistant.components.blink.Auth", autospec=True + ) as mock_blink_auth_api: + mock_blink_auth_api.check_key_required.return_value = False + yield mock_blink_auth_api + + +@pytest.fixture(name="mock_config_entry") +def mock_config_fixture(): + """Return a fake config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test_user", + CONF_PASSWORD: "Password", + "device_id": "Home Assistant", + "uid": "BlinkCamera_e1233333e2-0909-09cd-777a-123456789012", + "token": "A_token", + "host": "u034.immedia-semi.com", + "region_id": "u034", + "client_id": 123456, + "account_id": 654321, + }, + entry_id=str(uuid4()), + version=3, + ) diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..7fb13c97548 --- /dev/null +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'cameras': dict({ + 'Camera 1': dict({ + 'battery': 'ok', + 'battery_voltage': None, + 'camera_id': '111111', + 'last_record': None, + 'motion_detected': False, + 'motion_enabled': True, + 'name': 'Camera 1', + 'network_id': 222222, + 'recent_clips': list([ + ]), + 'serial': '**REDACTED**', + 'sync_module': 'sync module', + 'temperature': None, + 'temperature_c': 25.1, + 'temperature_calibrated': None, + 'thumbnail': 'https://rest-u034.immedia-semi.com/api/v3/media/accounts/111111/networks/222222/lotus/333333/thumbnail/thumbnail.jpg?ts=1698141602&ext=', + 'type': 'lotus', + 'video': None, + 'wifi_strength': None, + }), + }), + 'config_entry': dict({ + 'data': dict({ + 'account_id': 654321, + 'client_id': 123456, + 'device_id': 'Home Assistant', + 'host': 'u034.immedia-semi.com', + 'password': '**REDACTED**', + 'region_id': 'u034', + 'token': '**REDACTED**', + 'uid': 'BlinkCamera_e1233333e2-0909-09cd-777a-123456789012', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'blink', + 'options': dict({ + 'scan_interval': 300, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 3, + }), + }) +# --- diff --git a/tests/components/blink/test_diagnostics.py b/tests/components/blink/test_diagnostics.py new file mode 100644 index 00000000000..d447203dae6 --- /dev/null +++ b/tests/components/blink/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Test Blink diagnostics.""" +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +YAML_CONFIG = {"username": "test-user", "password": "test-password"} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_blink_api: MagicMock, + mock_config_entry: MagicMock, +) -> None: + """Test config entry diagnostics.""" + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("entry_id")) From 8ca1eaa839d3649f1f533cc0cf4a127f1812e65c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 13 Nov 2023 14:25:15 -0600 Subject: [PATCH 454/982] Bump intents and hassil (#103927) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 1b4d346082a..418342f714d 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.16"] + "requirements": ["hassil==1.5.0", "home-assistant-intents==2023.11.13"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c6a52301044..de234cee140 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,10 +25,10 @@ fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 -hassil==1.2.5 +hassil==1.5.0 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231030.2 -home-assistant-intents==2023.10.16 +home-assistant-intents==2023.11.13 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index eedbd2b21aa..bcc6daa1a12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -988,7 +988,7 @@ hass-nabucasa==0.74.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.2.5 +hassil==1.5.0 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -1021,7 +1021,7 @@ holidays==0.35 home-assistant-frontend==20231030.2 # homeassistant.components.conversation -home-assistant-intents==2023.10.16 +home-assistant-intents==2023.11.13 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b9fccb0e7d..e392f2d850f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -781,7 +781,7 @@ habitipy==0.2.0 hass-nabucasa==0.74.0 # homeassistant.components.conversation -hassil==1.2.5 +hassil==1.5.0 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -805,7 +805,7 @@ holidays==0.35 home-assistant-frontend==20231030.2 # homeassistant.components.conversation -home-assistant-intents==2023.10.16 +home-assistant-intents==2023.11.13 # homeassistant.components.home_connect homeconnect==0.7.2 From 74c51ec9e0acb5fe6ac8e9b22accf9dfa6b6ddc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Nov 2023 15:04:58 -0600 Subject: [PATCH 455/982] Bump zeroconf to 0.126.0 (#103934) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.125.0...0.126.0 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 7b47b854bd1..970e0dd39b6 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.125.0"] + "requirements": ["zeroconf==0.126.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index de234cee140..eaf7dc0dadd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.125.0 +zeroconf==0.126.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index bcc6daa1a12..39f24f68c09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2805,7 +2805,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.125.0 +zeroconf==0.126.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e392f2d850f..d4c496cb645 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2094,7 +2094,7 @@ yt-dlp==2023.10.13 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.125.0 +zeroconf==0.126.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From feabbfc3752ae90322ad6ee3dc4349eb3fde9c8c Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 13 Nov 2023 16:56:51 -0500 Subject: [PATCH 456/982] Remove unneeded self.async_write_ha_state() in Blink (#103932) --- homeassistant/components/blink/alarm_control_panel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index bf45ae7a582..8e0750d1373 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -104,4 +104,3 @@ class BlinkSyncModuleHA( raise HomeAssistantError("Blink failed to arm camera away") from er await self.coordinator.async_refresh() - self.async_write_ha_state() From 7ca264e7466b224cca57b789c5fcfb3d80a594b3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 13 Nov 2023 22:59:03 +0100 Subject: [PATCH 457/982] Fix raising vol.Invalid during mqtt config validation instead of ValueError (#103764) --- homeassistant/components/mqtt/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 358fa6eb675..3fa3ebfd30c 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -256,7 +256,7 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: CONF_HUMIDITY_STATE_TOPIC in config and CONF_HUMIDITY_COMMAND_TOPIC not in config ): - raise ValueError( + raise vol.Invalid( f"{CONF_HUMIDITY_STATE_TOPIC} cannot be used without" f" {CONF_HUMIDITY_COMMAND_TOPIC}" ) From cf6c72fdbd4038fdfd9f5598134456c2c256da8d Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 Nov 2023 07:15:19 +0000 Subject: [PATCH 458/982] Bump ring_doorbell to 0.8.0 and handle new exceptions (#103904) * Bump ring_doorbell to 0.8.0 and handle the new exceptions * Modify data update tests to not call coordinator internals --- homeassistant/components/ring/__init__.py | 30 ++-- homeassistant/components/ring/config_flow.py | 9 +- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ring/conftest.py | 71 +++++++- tests/components/ring/test_config_flow.py | 107 ++++++++---- tests/components/ring/test_init.py | 164 ++++++++++++++++++- 8 files changed, 333 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 56aad1a845b..a0863836a6c 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -8,9 +8,7 @@ from functools import partial import logging from typing import Any -from oauthlib.oauth2 import AccessDeniedError -import requests -from ring_doorbell import Auth, Ring +import ring_doorbell from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform, __version__ @@ -53,12 +51,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ).result() - auth = Auth(f"HomeAssistant/{__version__}", entry.data["token"], token_updater) - ring = Ring(auth) + auth = ring_doorbell.Auth( + f"HomeAssistant/{__version__}", entry.data["token"], token_updater + ) + ring = ring_doorbell.Ring(auth) try: await hass.async_add_executor_job(ring.update_data) - except AccessDeniedError: + except ring_doorbell.AuthenticationError: _LOGGER.error("Access token is no longer valid. Please set up Ring again") return False @@ -144,7 +144,7 @@ class GlobalDataUpdater: hass: HomeAssistant, data_type: str, config_entry_id: str, - ring: Ring, + ring: ring_doorbell.Ring, update_method: str, update_interval: timedelta, ) -> None: @@ -187,17 +187,17 @@ class GlobalDataUpdater: await self.hass.async_add_executor_job( getattr(self.ring, self.update_method) ) - except AccessDeniedError: + except ring_doorbell.AuthenticationError: _LOGGER.error("Ring access token is no longer valid. Set up Ring again") await self.hass.config_entries.async_unload(self.config_entry_id) return - except requests.Timeout: + except ring_doorbell.RingTimeout: _LOGGER.warning( "Time out fetching Ring %s data", self.data_type, ) return - except requests.RequestException as err: + except ring_doorbell.RingError as err: _LOGGER.warning( "Error fetching Ring %s data: %s", self.data_type, @@ -217,8 +217,8 @@ class DeviceDataUpdater: hass: HomeAssistant, data_type: str, config_entry_id: str, - ring: Ring, - update_method: Callable[[Ring], Any], + ring: ring_doorbell.Ring, + update_method: Callable[[ring_doorbell.Ring], Any], update_interval: timedelta, ) -> None: """Initialize device data updater.""" @@ -276,20 +276,20 @@ class DeviceDataUpdater: for device_id, info in self.devices.items(): try: data = info["data"] = self.update_method(info["device"]) - except AccessDeniedError: + except ring_doorbell.AuthenticationError: _LOGGER.error("Ring access token is no longer valid. Set up Ring again") self.hass.add_job( self.hass.config_entries.async_unload(self.config_entry_id) ) return - except requests.Timeout: + except ring_doorbell.RingTimeout: _LOGGER.warning( "Time out fetching Ring %s data for device %s", self.data_type, device_id, ) continue - except requests.RequestException as err: + except ring_doorbell.RingError as err: _LOGGER.warning( "Error fetching Ring %s data for device %s: %s", self.data_type, diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 9425b2f98a4..b22d59a78f5 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -2,8 +2,7 @@ import logging from typing import Any -from oauthlib.oauth2 import AccessDeniedError, MissingTokenError -from ring_doorbell import Auth +import ring_doorbell import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -17,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect.""" - auth = Auth(f"HomeAssistant/{ha_version}") + auth = ring_doorbell.Auth(f"HomeAssistant/{ha_version}") try: token = await hass.async_add_executor_job( @@ -26,9 +25,9 @@ async def validate_input(hass: core.HomeAssistant, data): data["password"], data.get("2fa"), ) - except MissingTokenError as err: + except ring_doorbell.Requires2FAError as err: raise Require2FA from err - except AccessDeniedError as err: + except ring_doorbell.AuthenticationError as err: raise InvalidAuth from err return token diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 9cea738eb3a..8abf73e7fed 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell==0.7.3"] + "requirements": ["ring-doorbell==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 39f24f68c09..25f5e600a1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2345,7 +2345,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.7.3 +ring-doorbell==0.8.0 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4c496cb645..87bb0f1adc9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1748,7 +1748,7 @@ reolink-aio==0.7.15 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.7.3 +ring-doorbell==0.8.0 # homeassistant.components.roku rokuecp==0.18.1 diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index 2b6edf86132..e9800393835 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -1,13 +1,74 @@ """Configuration for Ring tests.""" +from collections.abc import Generator import re +from unittest.mock import AsyncMock, Mock, patch import pytest import requests_mock -from tests.common import load_fixture +from homeassistant.components.ring import DOMAIN +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ring.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_ring_auth(): + """Mock ring_doorbell.Auth.""" + with patch("ring_doorbell.Auth", autospec=True) as mock_ring_auth: + mock_ring_auth.return_value.fetch_token.return_value = { + "access_token": "mock-token" + } + yield mock_ring_auth.return_value + + +@pytest.fixture +def mock_ring(): + """Mock ring_doorbell.Ring.""" + with patch("ring_doorbell.Ring", autospec=True) as mock_ring: + yield mock_ring.return_value + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="Ring", + domain=DOMAIN, + data={ + CONF_USERNAME: "foo@bar.com", + "token": {"access_token": "mock-token"}, + }, + unique_id="foo@bar.com", + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ring_auth: Mock, + mock_ring: Mock, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry + + @pytest.fixture(name="requests_mock") def requests_mock_fixture(): """Fixture to provide a requests mocker.""" @@ -52,5 +113,11 @@ def requests_mock_fixture(): re.compile(r"https:\/\/api\.ring\.com\/clients_api\/chimes\/\d+\/health"), text=load_fixture("chime_health_attrs.json", "ring"), ) - + mock.get( + re.compile( + r"https:\/\/api\.ring\.com\/clients_api\/dings\/\d+\/share/play" + ), + status_code=200, + json={"url": "http://127.0.0.1/foo"}, + ) yield mock diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index 3e0c354e8fa..0c1578e2c8d 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -1,13 +1,21 @@ """Test the Ring config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock + +import pytest +import ring_doorbell from homeassistant import config_entries from homeassistant.components.ring import DOMAIN -from homeassistant.components.ring.config_flow import InvalidAuth +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_ring_auth: Mock, +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -16,20 +24,11 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.ring.config_flow.Auth", - return_value=Mock( - fetch_token=Mock(return_value={"access_token": "mock-token"}) - ), - ), patch( - "homeassistant.components.ring.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "hello@home-assistant.io", "password": "test-password"}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "hello@home-assistant.io", "password": "test-password"}, + ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "hello@home-assistant.io" @@ -40,20 +39,72 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("error_type", "errors_msg"), + [ + (ring_doorbell.AuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_form_error( + hass: HomeAssistant, mock_ring_auth: Mock, error_type, errors_msg +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.ring.config_flow.Auth.fetch_token", - side_effect=InvalidAuth, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "hello@home-assistant.io", "password": "test-password"}, - ) + mock_ring_auth.fetch_token.side_effect = error_type + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "hello@home-assistant.io", "password": "test-password"}, + ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["errors"] == {"base": errors_msg} + + +async def test_form_2fa( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_ring_auth: Mock, +) -> None: + """Test form flow for 2fa.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + mock_ring_auth.fetch_token.side_effect = ring_doorbell.Requires2FAError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "fake-password", + }, + ) + await hass.async_block_till_done() + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "fake-password", None + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "2fa" + mock_ring_auth.fetch_token.reset_mock(side_effect=True) + mock_ring_auth.fetch_token.return_value = "new-foobar" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={"2fa": "123456"}, + ) + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "fake-password", "123456" + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "foo@bar.com" + assert result3["data"] == { + "username": "foo@bar.com", + "token": "new-foobar", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 7e3f5344f73..9fa79b21fab 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -1,12 +1,20 @@ """The tests for the Ring component.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest import requests_mock +from ring_doorbell import AuthenticationError, RingError, RingTimeout import homeassistant.components.ring as ring +from homeassistant.components.ring import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import load_fixture +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture async def test_setup(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: @@ -32,3 +40,157 @@ async def test_setup(hass: HomeAssistant, requests_mock: requests_mock.Mocker) - "https://api.ring.com/clients_api/doorbots/987652/health", text=load_fixture("doorboot_health_attrs.json", "ring"), ) + + +async def test_auth_failed_on_setup( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, +) -> None: + """Test auth failure on setup entry.""" + mock_config_entry.add_to_hass(hass) + with patch( + "ring_doorbell.Ring.update_data", + side_effect=AuthenticationError, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert result is False + assert "Access token is no longer valid. Please set up Ring again" in [ + record.message for record in caplog.records if record.levelname == "ERROR" + ] + + assert DOMAIN not in hass.data + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_auth_failure_on_global_update( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, +) -> None: + """Test authentication failure on global data update.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "ring_doorbell.Ring.update_devices", + side_effect=AuthenticationError, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + + assert "Ring access token is no longer valid. Set up Ring again" in [ + record.message for record in caplog.records if record.levelname == "ERROR" + ] + + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_auth_failure_on_device_update( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, +) -> None: + """Test authentication failure on global data update.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "ring_doorbell.RingDoorBell.history", + side_effect=AuthenticationError, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + + assert "Ring access token is no longer valid. Set up Ring again" in [ + record.message for record in caplog.records if record.levelname == "ERROR" + ] + + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + + +@pytest.mark.parametrize( + ("error_type", "log_msg"), + [ + ( + RingTimeout, + "Time out fetching Ring device data", + ), + ( + RingError, + "Error fetching Ring device data: ", + ), + ], + ids=["timeout-error", "other-error"], +) +async def test_error_on_global_update( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, + error_type, + log_msg, +) -> None: + """Test error on global data update.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "ring_doorbell.Ring.update_devices", + side_effect=error_type, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + + assert log_msg in [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + + assert mock_config_entry.entry_id in hass.data[DOMAIN] + + +@pytest.mark.parametrize( + ("error_type", "log_msg"), + [ + ( + RingTimeout, + "Time out fetching Ring history data for device aacdef123", + ), + ( + RingError, + "Error fetching Ring history data for device aacdef123: ", + ), + ], + ids=["timeout-error", "other-error"], +) +async def test_error_on_device_update( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, + error_type, + log_msg, +) -> None: + """Test auth failure on data update.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "ring_doorbell.RingDoorBell.history", + side_effect=error_type, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + + assert log_msg in [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + assert mock_config_entry.entry_id in hass.data[DOMAIN] From 9241554d45dca44afa6c032af74735551cef38c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Nov 2023 08:19:42 +0100 Subject: [PATCH 459/982] Bump dessant/lock-threads from 4.0.1 to 5.0.0 (#103954) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 2b5364fa950..b4fedc57218 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4.0.1 + - uses: dessant/lock-threads@v5.0.0 with: github-token: ${{ github.token }} issue-inactive-days: "30" From dedd3418a142d9e816dcf91a4ddffec59ebe82d4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Nov 2023 08:21:36 +0100 Subject: [PATCH 460/982] Improve print of line numbers when there are configuration errors (#103216) * Improve print of line numbers when there are configuration errors * Update alarm_control_panel test --- homeassistant/config.py | 95 ++++++++++++++++--- .../template/test_alarm_control_panel.py | 2 +- tests/helpers/test_check_config.py | 19 ++-- tests/snapshots/test_config.ambr | 40 ++++---- 4 files changed, 113 insertions(+), 43 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index abe14adb2ef..17a6f32336f 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -4,7 +4,9 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Callable, Sequence from contextlib import suppress +from functools import reduce import logging +import operator import os from pathlib import Path import re @@ -505,6 +507,77 @@ def async_log_exception( _LOGGER.error(message, exc_info=not is_friendly and ex) +def _get_annotation(item: Any) -> tuple[str, int | str] | None: + if not hasattr(item, "__config_file__"): + return None + + return (getattr(item, "__config_file__"), getattr(item, "__line__", "?")) + + +def _get_by_path(data: dict | list, items: list[str | int]) -> Any: + """Access a nested object in root by item sequence. + + Returns None in case of error. + """ + try: + return reduce(operator.getitem, items, data) # type: ignore[arg-type] + except (KeyError, IndexError, TypeError): + return None + + +def find_annotation( + config: dict | list, path: list[str | int] +) -> tuple[str, int | str] | None: + """Find file/line annotation for a node in config pointed to by path. + + If the node pointed to is a dict or list, prefer the annotation for the key in + the key/value pair defining the dict or list. + If the node is not annotated, try the parent node. + """ + + def find_annotation_for_key( + item: dict, path: list[str | int], tail: str | int + ) -> tuple[str, int | str] | None: + for key in item: + if key == tail: + if annotation := _get_annotation(key): + return annotation + break + return None + + def find_annotation_rec( + config: dict | list, path: list[str | int], tail: str | int | None + ) -> tuple[str, int | str] | None: + item = _get_by_path(config, path) + if isinstance(item, dict) and tail is not None: + if tail_annotation := find_annotation_for_key(item, path, tail): + return tail_annotation + + if ( + isinstance(item, (dict, list)) + and path + and ( + key_annotation := find_annotation_for_key( + _get_by_path(config, path[:-1]), path[:-1], path[-1] + ) + ) + ): + return key_annotation + + if annotation := _get_annotation(item): + return annotation + + if not path: + return None + + tail = path.pop() + if annotation := find_annotation_rec(config, path, tail): + return annotation + return _get_annotation(item) + + return find_annotation_rec(config, list(path), None) + + @callback def _format_config_error( ex: Exception, domain: str, config: dict, link: str | None = None @@ -514,30 +587,26 @@ def _format_config_error( This method must be run in the event loop. """ is_friendly = False - message = f"Invalid config for [{domain}]: " + message = f"Invalid config for [{domain}]" + if isinstance(ex, vol.Invalid): + if annotation := find_annotation(config, ex.path): + message += f" at {annotation[0]}, line {annotation[1]}: " + else: + message += ": " + if "extra keys not allowed" in ex.error_message: path = "->".join(str(m) for m in ex.path) message += ( - f"[{ex.path[-1]}] is an invalid option for [{domain}]. " - f"Check: {domain}->{path}." + f"'{ex.path[-1]}' is an invalid option for [{domain}], check: {path}" ) else: message += f"{humanize_error(config, ex)}." is_friendly = True else: + message += ": " message += str(ex) or repr(ex) - try: - domain_config = config.get(domain, config) - except AttributeError: - domain_config = config - - message += ( - f" (See {getattr(domain_config, '__config_file__', '?')}, " - f"line {getattr(domain_config, '__line__', '?')})." - ) - if domain != CONF_CORE and link: message += f" Please check the docs at {link}" diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index dd4fa1d32a5..9210b9ea738 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -198,7 +198,7 @@ async def test_optimistic_states(hass: HomeAssistant, start_ha) -> None: "wibble": {"test_panel": "Invalid"}, } }, - "[wibble] is an invalid option", + "'wibble' is an invalid option", ), ( { diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index a62bd8b39e4..baec4ae04a9 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -81,9 +81,10 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: error = CheckConfigError( ( - "Invalid config for [homeassistant]: not a valid value for dictionary " - "value @ data['unit_system']. Got 'bad'. (See " - f"{hass.config.path(YAML_CONFIG_FILE)}, line 2)." + "Invalid config for [homeassistant] at " + f"{hass.config.path(YAML_CONFIG_FILE)}, line 2: " + "not a valid value for dictionary value @ data['unit_system']. Got " + "'bad'." ), "homeassistant", {"unit_system": "bad"}, @@ -190,9 +191,9 @@ async def test_component_import_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("component", "errors", "warnings", "message"), [ - ("frontend", 1, 0, "[blah] is an invalid option for [frontend]"), - ("http", 1, 0, "[blah] is an invalid option for [http]"), - ("logger", 0, 1, "[blah] is an invalid option for [logger]"), + ("frontend", 1, 0, "'blah' is an invalid option for [frontend]"), + ("http", 1, 0, "'blah' is an invalid option for [http]"), + ("logger", 0, 1, "'blah' is an invalid option for [logger]"), ], ) async def test_component_schema_error( @@ -274,21 +275,21 @@ async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: ( "blah:\n - platform: test\n option1: 123", 1, - "Invalid config for [blah.test]: expected str for dictionary value", + "expected str for dictionary value", {"option1": 123, "platform": "test"}, ), # Test the attached config is unvalidated (key old is removed by validator) ( "blah:\n - platform: test\n old: blah\n option1: 123", 1, - "Invalid config for [blah.test]: expected str for dictionary value", + "expected str for dictionary value", {"old": "blah", "option1": 123, "platform": "test"}, ), # Test base platform configuration error ( "blah:\n - paltfrom: test\n", 1, - "Invalid config for [blah]: required key not provided", + "required key not provided", {"paltfrom": "test"}, ), ], diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index e7afa47537a..fd5e084d8ce 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,46 +1,46 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 6).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 9).", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 20).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 7: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: required key not provided @ data['platform']. Got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 16: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 21: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_component_config_validation_error[basic_include] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8).", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 4).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 6: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: required key not provided @ data['platform']. Got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_component_config_validation_error[include_dir_list] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 2).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 2: required key not provided @ data['platform']. Got None.", ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 2).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5: required key not provided @ data['platform']. Got None.", ]) # --- # name: test_component_config_validation_error[packages] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 11).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 16).", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 12: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: required key not provided @ data['platform']. Got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 23: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 28: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9).", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 7: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: required key not provided @ data['platform']. Got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_package_merge_error[packages] From 7a060176b6ca779358c6b8f2b3ab4ef5d4e4b659 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 14 Nov 2023 02:30:15 -0500 Subject: [PATCH 461/982] Bump zwave-js-server-python to 0.54.0 (#103943) --- homeassistant/components/zwave_js/cover.py | 3 ++- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 364eafd8caf..27919a17614 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -18,6 +18,7 @@ from zwave_js_server.const.command_class.multilevel_switch import ( from zwave_js_server.const.command_class.window_covering import ( NO_POSITION_PROPERTY_KEYS, NO_POSITION_SUFFIX, + WINDOW_COVERING_LEVEL_CHANGE_UP_PROPERTY, SlatStates, ) from zwave_js_server.model.driver import Driver @@ -369,7 +370,7 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): set_values_func( value, stop_value=self.get_zwave_value( - "levelChangeUp", + WINDOW_COVERING_LEVEL_CHANGE_UP_PROPERTY, value_property_key=value.property_key, ), ) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index f0c1dcec6b5..f2d32d499c9 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.53.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.54.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 25f5e600a1a..a5844abc496 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2838,7 +2838,7 @@ zigpy==0.59.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.53.1 +zwave-js-server-python==0.54.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87bb0f1adc9..35b409a245f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2118,7 +2118,7 @@ zigpy-znp==0.11.6 zigpy==0.59.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.53.1 +zwave-js-server-python==0.54.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 96bcc6c35a5c31fd90d08516b747f71479cd1a87 Mon Sep 17 00:00:00 2001 From: fb22 <4872297+fb22@users.noreply.github.com> Date: Tue, 14 Nov 2023 09:18:37 +0100 Subject: [PATCH 462/982] Add Vicare volumetric flow and compressor phase sensors (#103875) Co-authored-by: Joost Lekkerkerker Co-authored-by: Robert Resch --- homeassistant/components/vicare/sensor.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 325f3bf2d07..99f1eef80b9 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -24,11 +24,13 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfEnergy, UnitOfPower, UnitOfTemperature, UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -477,6 +479,15 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="volumetric_flow", + name="Volumetric flow", + icon="mdi:gauge", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + value_getter=lambda api: api.getVolumetricFlowReturn() / 1000, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( @@ -572,6 +583,13 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( value_getter=lambda api: api.getHoursLoadClass5(), state_class=SensorStateClass.TOTAL_INCREASING, ), + ViCareSensorEntityDescription( + key="compressor_phase", + name="Compressor Phase", + icon="mdi:information", + value_getter=lambda api: api.getPhase(), + entity_category=EntityCategory.DIAGNOSTIC, + ), ) From 0a84c2dba6d766ae3a5764a3f206b382b115faff Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Tue, 14 Nov 2023 01:17:44 -0800 Subject: [PATCH 463/982] Update smarttub to 0.0.36 (#103948) --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index e8db096f31d..f2514063a40 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["smarttub"], "quality_scale": "platinum", - "requirements": ["python-smarttub==0.0.35"] + "requirements": ["python-smarttub==0.0.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index a5844abc496..7dd72942348 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2198,7 +2198,7 @@ python-ripple-api==0.0.3 python-roborock==0.36.1 # homeassistant.components.smarttub -python-smarttub==0.0.35 +python-smarttub==0.0.36 # homeassistant.components.songpal python-songpal==0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35b409a245f..f7d68d92749 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1637,7 +1637,7 @@ python-qbittorrent==0.4.3 python-roborock==0.36.1 # homeassistant.components.smarttub -python-smarttub==0.0.35 +python-smarttub==0.0.36 # homeassistant.components.songpal python-songpal==0.16 From fe15ed4a28541d553da17ac9c8f91454e36c8108 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 14 Nov 2023 10:50:55 +0100 Subject: [PATCH 464/982] Add device info to generic camera (#103715) Co-authored-by: Franck Nijhof --- homeassistant/components/generic/camera.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 621566a70f5..9ffd873efd6 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -172,6 +173,11 @@ class GenericCamera(Camera): self._last_url = None self._last_image = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="Generic", + ) + @property def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" From 44c1cef42edd9cc63bb85b63a46463b7753972b9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Nov 2023 11:26:22 +0100 Subject: [PATCH 465/982] Add tests for component configuration with extra keys (#103959) --- .../basic/configuration.yaml | 14 +++++-- .../integrations/adr_0007_4.yaml | 3 ++ .../integrations/iot_domain.yaml | 9 +++-- .../iot_domain/iot_domain_2.yaml | 5 +-- .../iot_domain/iot_domain_3.yaml | 5 ++- .../iot_domain/iot_domain_4.yaml | 3 ++ .../iot_domain/iot_domain_1.yaml | 2 + .../iot_domain/iot_domain_2.yaml | 7 ++-- .../packages/configuration.yaml | 28 ++++++++----- .../integrations/adr_0007_4.yaml | 4 ++ .../integrations/iot_domain.yaml | 9 +++-- tests/snapshots/test_config.ambr | 39 ++++++++++++------- tests/test_config.py | 10 ++++- 13 files changed, 95 insertions(+), 43 deletions(-) create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml create mode 100644 tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml index 5b3aacd9523..f6ec9e65388 100644 --- a/tests/fixtures/core/config/component_validation/basic/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -2,11 +2,14 @@ iot_domain: # This is correct and should not generate errors - platform: non_adr_0007 option1: abc - # This violates the non_adr_0007.iot_domain platform schema + # This violates the iot_domain platform schema (platform missing) + - paltfrom: non_adr_0007 + # This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) - platform: non_adr_0007 option1: 123 - # This violates the iot_domain platform schema - - paltfrom: non_adr_0007 + # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) + - platform: non_adr_0007 + no_such_option: abc # This is correct and should not generate errors adr_0007_1: @@ -19,3 +22,8 @@ adr_0007_2: adr_0007_3: host: blah.com port: foo + +# no_such_option does not exist +adr_0007_4: + host: blah.com + no_such_option: foo diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml new file mode 100644 index 00000000000..e8dcd8f4017 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml @@ -0,0 +1,3 @@ +# no_such_option does not exist +host: blah.com +no_such_option: foo diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml index 405fc3aab91..4ce54be7541 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml @@ -1,8 +1,11 @@ # This is correct and should not generate errors - platform: non_adr_0007 option1: abc -# This violates the non_adr_0007.iot_domain platform schema +# This violates the iot_domain platform schema (platform missing) +- paltfrom: non_adr_0007 +# This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) - platform: non_adr_0007 option1: 123 -# This violates the iot_domain platform schema -- paltfrom: non_adr_0007 +# This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) +- platform: non_adr_0007 + no_such_option: abc diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml index f4d009c8cfa..f6c3219741e 100644 --- a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml @@ -1,3 +1,2 @@ -# This violates the non_adr_0007.iot_domain platform schema -platform: non_adr_0007 -option1: 123 +# This violates the iot_domain platform schema (platform missing) +paltfrom: non_adr_0007 diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml index 94c18721061..2265e8c2f07 100644 --- a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml @@ -1,2 +1,3 @@ -# This violates the iot_domain platform schema -paltfrom: non_adr_0007 +# This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) +platform: non_adr_0007 +option1: 123 diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml new file mode 100644 index 00000000000..90be03bd5f9 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml @@ -0,0 +1,3 @@ +# This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) +platform: non_adr_0007 +no_such_option: abc diff --git a/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml index a0636cdecf4..172f96e2da2 100644 --- a/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml +++ b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml @@ -1,3 +1,5 @@ # This is correct and should not generate errors - platform: non_adr_0007 option1: abc +# This violates the iot_domain platform schema (platform missing) +- paltfrom: non_adr_0007 diff --git a/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml index 16df25adcd7..d379c968b16 100644 --- a/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml +++ b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml @@ -1,5 +1,6 @@ -# This violates the non_adr_0007.iot_domain platform schema +# This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) - platform: non_adr_0007 option1: 123 - # This violates the iot_domain platform schema -- paltfrom: non_adr_0007 +# This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) +- platform: non_adr_0007 + no_such_option: abc diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml index 5b3cf74615a..cd1aa71ef3f 100644 --- a/tests/fixtures/core/config/component_validation/packages/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -1,28 +1,38 @@ homeassistant: packages: - pack_1: + pack_iot_domain_1: iot_domain: # This is correct and should not generate errors - platform: non_adr_0007 option1: abc - pack_2: + pack_iot_domain_2: iot_domain: - # This violates the non_adr_0007.iot_domain platform schema + # This violates the iot_domain platform schema (platform missing) + - paltfrom: non_adr_0007 + pack_iot_domain_3: + iot_domain: + # This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) - platform: non_adr_0007 option1: 123 - pack_3: + pack_iot_domain_4: iot_domain: - # This violates the iot_domain platform schema - - paltfrom: non_adr_0007 - pack_4: + # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) + - platform: non_adr_0007 + no_such_option: abc + pack_adr_0007_1: # This is correct and should not generate errors adr_0007_1: host: blah.com - pack_5: + pack_adr_0007_2: # Host is missing adr_0007_2: - pack_6: + pack_adr_0007_3: # Port is wrong type adr_0007_3: host: blah.com port: foo + pack_adr_0007_4: + # no_such_option does not exist + adr_0007_4: + host: blah.com + no_such_option: foo diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml new file mode 100644 index 00000000000..b5d4602c683 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml @@ -0,0 +1,4 @@ +# no_such_option does not exist +adr_0007_4: + host: blah.com + no_such_option: foo diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml index 8c366297165..bf18c0f74cf 100644 --- a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml @@ -2,8 +2,11 @@ iot_domain: # This is correct and should not generate errors - platform: non_adr_0007 option1: abc - # This violates the non_adr_0007.iot_domain platform schema + # This violates the iot_domain platform schema (platform missing) + - paltfrom: non_adr_0007 + # This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) - platform: non_adr_0007 option1: 123 - # This violates the iot_domain platform schema - - paltfrom: non_adr_0007 + # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) + - platform: non_adr_0007 + - no_such_option: abc diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index fd5e084d8ce..9d72d931fa2 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,46 +1,55 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 7: expected str for dictionary value @ data['option1']. Got 123.", - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: required key not provided @ data['platform']. Got None.", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 16: required key not provided @ data['adr_0007_2']['host']. Got None.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 21: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 24: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 29: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ]) # --- # name: test_component_config_validation_error[basic_include] list([ - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 6: expected str for dictionary value @ data['option1']. Got 123.", - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key not provided @ data['adr_0007_2']['host']. Got None.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_component_config_validation_error[include_dir_list] list([ - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 2: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] list([ + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key not provided @ data['platform']. Got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ]) # --- # name: test_component_config_validation_error[packages] list([ - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 12: expected str for dictionary value @ data['option1']. Got 123.", - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: required key not provided @ data['platform']. Got None.", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 23: required key not provided @ data['adr_0007_2']['host']. Got None.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 28: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 28: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 33: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 7: expected str for dictionary value @ data['option1']. Got 123.", - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key not provided @ data['platform']. Got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: required key not provided @ data['platform']. Got None.", "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key not provided @ data['adr_0007_2']['host']. Got None.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ]) # --- # name: test_package_merge_error[packages] diff --git a/tests/test_config.py b/tests/test_config.py index d97d4f7a2c8..56861057526 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -133,7 +133,7 @@ async def mock_non_adr_0007_integration(hass) -> None: async def mock_adr_0007_integrations(hass) -> list[Integration]: """Mock ADR-0007 compliant integrations.""" integrations = [] - for domain in ["adr_0007_1", "adr_0007_2", "adr_0007_3"]: + for domain in ["adr_0007_1", "adr_0007_2", "adr_0007_3", "adr_0007_4"]: adr_0007_config_schema = vol.Schema( { domain: vol.Schema( @@ -1498,7 +1498,13 @@ async def test_component_config_validation_error( ) config = await config_util.async_hass_config_yaml(hass) - for domain in ["iot_domain", "adr_0007_1", "adr_0007_2", "adr_0007_3"]: + for domain in [ + "iot_domain", + "adr_0007_1", + "adr_0007_2", + "adr_0007_3", + "adr_0007_4", + ]: integration = await async_get_integration(hass, domain) await config_util.async_process_component_config( hass, From 85eac5a1b15061dfeab6ae1496e5707d71e9bd30 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Nov 2023 11:48:56 +0100 Subject: [PATCH 466/982] Add additional test for package errors (#103955) * Add additional test for package errors * Adjust tests --- .../core/config/package_errors/packages/configuration.yaml | 3 +++ .../integrations/unknown_integration.yaml | 3 +++ tests/snapshots/test_config.ambr | 2 ++ 3 files changed, 8 insertions(+) create mode 100644 tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml diff --git a/tests/fixtures/core/config/package_errors/packages/configuration.yaml b/tests/fixtures/core/config/package_errors/packages/configuration.yaml index 498eca0edac..19ec6e1e983 100644 --- a/tests/fixtures/core/config/package_errors/packages/configuration.yaml +++ b/tests/fixtures/core/config/package_errors/packages/configuration.yaml @@ -19,3 +19,6 @@ homeassistant: pack_4: adr_0007_3: host: blah.com + pack_5: + unknown_integration: + host: blah.com diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml new file mode 100644 index 00000000000..d041b77ea29 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml @@ -0,0 +1,3 @@ +# Unknown integration +unknown_integration: + host: blah.com diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 9d72d931fa2..fbf37e69ead 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -57,6 +57,7 @@ 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages/configuration.yaml:9).', 'Package pack_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages/configuration.yaml:13).', "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages/configuration.yaml:20).", + "Package pack_5 setup failed. Integration unknown_integration Integration 'unknown_integration' not found. (See /fixtures/core/config/package_errors/packages/configuration.yaml:23).", ]) # --- # name: test_package_merge_error[packages_include_dir_named] @@ -64,6 +65,7 @@ 'Package adr_0007_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml:2).', 'Package adr_0007_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml:2).', "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml:1).", + "Package unknown_integration setup failed. Integration unknown_integration Integration 'unknown_integration' not found. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml:2).", ]) # --- # name: test_yaml_error[basic] From 94a2087ba080ad955d7b3fb4379087f7095e0d08 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Nov 2023 12:48:45 +0100 Subject: [PATCH 467/982] Improve formatting of config validation errors (#103957) * Improve formatting of config validation errors * Address review comments --- homeassistant/config.py | 43 ++++++++++++++++++- .../template/test_alarm_control_panel.py | 2 +- tests/helpers/test_check_config.py | 3 +- tests/snapshots/test_config.ambr | 42 +++++++++--------- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 17a6f32336f..b35ba910ac3 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -17,7 +17,7 @@ from urllib.parse import urlparse from awesomeversion import AwesomeVersion import voluptuous as vol -from voluptuous.humanize import humanize_error +from voluptuous.humanize import MAX_VALIDATION_ERROR_ITEM_LENGTH from . import auth from .auth import mfa_modules as auth_mfa_modules, providers as auth_providers @@ -578,6 +578,47 @@ def find_annotation( return find_annotation_rec(config, list(path), None) +def stringify_invalid(ex: vol.Invalid) -> str: + """Stringify voluptuous.Invalid. + + Based on voluptuous.error.Invalid.__str__, the main modification + is to format the path delimited by -> instead of @data[]. + """ + path = "->".join(str(m) for m in ex.path) + # This function is an alternative to the stringification done by + # vol.Invalid.__str__, so we need to call Exception.__str__ here + # instead of str(ex) + output = Exception.__str__(ex) + if error_type := ex.error_type: + output += " for " + error_type + return f"{output} '{path}'" + + +def humanize_error( + data: Any, + validation_error: vol.Invalid, + max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH, +) -> str: + """Provide a more helpful + complete validation error message. + + This is a modified version of voluptuous.error.Invalid.__str__, + the modifications make some minor changes to the formatting. + """ + if isinstance(validation_error, vol.MultipleInvalid): + return "\n".join( + sorted( + humanize_error(data, sub_error, max_sub_error_length) + for sub_error in validation_error.errors + ) + ) + offending_item_summary = repr(_get_by_path(data, validation_error.path)) + if len(offending_item_summary) > max_sub_error_length: + offending_item_summary = ( + f"{offending_item_summary[: max_sub_error_length - 3]}..." + ) + return f"{stringify_invalid(validation_error)}, got {offending_item_summary}" + + @callback def _format_config_error( ex: Exception, domain: str, config: dict, link: str | None = None diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 9210b9ea738..d04757fb808 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -204,7 +204,7 @@ async def test_optimistic_states(hass: HomeAssistant, start_ha) -> None: { "alarm_control_panel": {"platform": "template"}, }, - "required key not provided @ data['panels']", + "required key not provided 'panels'", ), ( { diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index baec4ae04a9..11f65fa4e5e 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -83,8 +83,7 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: ( "Invalid config for [homeassistant] at " f"{hass.config.path(YAML_CONFIG_FILE)}, line 2: " - "not a valid value for dictionary value @ data['unit_system']. Got " - "'bad'." + "not a valid value for dictionary value 'unit_system', got 'bad'." ), "homeassistant", {"unit_system": "bad"}, diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index fbf37e69ead..33b5e193d87 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,54 +1,54 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided @ data['platform']. Got None.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided 'platform', got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: required key not provided @ data['adr_0007_2']['host']. Got None.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 24: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 24: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 29: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ]) # --- # name: test_component_config_validation_error[basic_include] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key not provided @ data['platform']. Got None.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key not provided 'platform', got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key not provided @ data['adr_0007_2']['host']. Got None.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", ]) # --- # name: test_component_config_validation_error[include_dir_list] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key not provided @ data['platform']. Got None.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key not provided 'platform', got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key not provided @ data['platform']. Got None.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key not provided 'platform', got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ]) # --- # name: test_component_config_validation_error[packages] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key not provided @ data['platform']. Got None.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key not provided 'platform', got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 28: required key not provided @ data['adr_0007_2']['host']. Got None.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 33: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 28: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 33: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key not provided @ data['platform']. Got None.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value @ data['option1']. Got 123.", - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: required key not provided @ data['platform']. Got None.", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key not provided @ data['adr_0007_2']['host']. Got None.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key not provided 'platform', got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: required key not provided 'platform', got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ]) # --- From 2d39eaf0a21e9a4cb148aa7f1791bcc34e94aea9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Nov 2023 14:05:46 +0100 Subject: [PATCH 468/982] Improve docstring of config.stringify_invalid (#103965) --- homeassistant/config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index b35ba910ac3..569c059e9c2 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -581,8 +581,9 @@ def find_annotation( def stringify_invalid(ex: vol.Invalid) -> str: """Stringify voluptuous.Invalid. - Based on voluptuous.error.Invalid.__str__, the main modification - is to format the path delimited by -> instead of @data[]. + This is an alternative to the custom __str__ implemented in + voluptuous.error.Invalid. The main modification is to format + the path delimited by -> instead of @data[]. """ path = "->".join(str(m) for m in ex.path) # This function is an alternative to the stringification done by From 381ebf3e53d18cd801efb6e3b9bbf3159d18f9a5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Nov 2023 15:08:20 +0100 Subject: [PATCH 469/982] Add tests for component configuration with multiple errors (#103964) * Add tests for component configuration with multiple errors * Add new configuration file * Fix typo --------- Co-authored-by: Martin Hjelmare --- .../basic/configuration.yaml | 16 ++++++++++++ .../basic_include/configuration.yaml | 2 ++ .../integrations/adr_0007_5.yaml | 6 +++++ .../integrations/iot_domain.yaml | 8 ++++++ .../iot_domain/iot_domain_4.yaml | 1 + .../iot_domain/iot_domain_5.yaml | 7 ++++++ .../iot_domain/iot_domain_2.yaml | 8 ++++++ .../packages/configuration.yaml | 18 +++++++++++++ .../integrations/adr_0007_5.yaml | 7 ++++++ .../integrations/iot_domain.yaml | 10 +++++++- tests/snapshots/test_config.ambr | 25 +++++++++++++------ tests/test_config.py | 13 ++++++++-- 12 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml create mode 100644 tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml index f6ec9e65388..158a32a7d69 100644 --- a/tests/fixtures/core/config/component_validation/basic/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -10,6 +10,14 @@ iot_domain: # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) - platform: non_adr_0007 no_such_option: abc + option1: abc + # This violates the non_adr_0007.iot_domain platform schema: + # - no_such_option does not exist + # - option1 is missing + # - option2 is wrong type + - platform: non_adr_0007 + no_such_option: abc + option2: 123 # This is correct and should not generate errors adr_0007_1: @@ -27,3 +35,11 @@ adr_0007_3: adr_0007_4: host: blah.com no_such_option: foo + +# Multiple errors: +# - host is missing +# - no_such_option does not exist +# - port is wrong type +adr_0007_5: + no_such_option: foo + port: foo diff --git a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml index ab86a6b34da..d67ae673901 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -2,3 +2,5 @@ iot_domain: !include integrations/iot_domain.yaml adr_0007_1: !include integrations/adr_0007_1.yaml adr_0007_2: !include integrations/adr_0007_2.yaml adr_0007_3: !include integrations/adr_0007_3.yaml +adr_0007_4: !include integrations/adr_0007_4.yaml +adr_0007_5: !include integrations/adr_0007_5.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml new file mode 100644 index 00000000000..0cda3d04a55 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml @@ -0,0 +1,6 @@ +# Multiple errors: +# - host is missing +# - no_such_option does not exist +# - port is wrong type +no_such_option: foo +port: foo diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml index 4ce54be7541..dd592194f1a 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml @@ -9,3 +9,11 @@ # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) - platform: non_adr_0007 no_such_option: abc + option1: abc +# This violates the non_adr_0007.iot_domain platform schema: +# - no_such_option does not exist +# - option1 is missing +# - option2 is wrong type +- platform: non_adr_0007 + no_such_option: abc + option2: 123 diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml index 90be03bd5f9..53f220472e2 100644 --- a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml @@ -1,3 +1,4 @@ # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) platform: non_adr_0007 no_such_option: abc +option1: abc diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml new file mode 100644 index 00000000000..b0fec6d5046 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml @@ -0,0 +1,7 @@ +# This violates the non_adr_0007.iot_domain platform schema: +# - no_such_option does not exist +# - option1 is missing +# - option2 is wrong type +platform: non_adr_0007 +no_such_option: abc +option2: 123 diff --git a/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml index d379c968b16..f8ef2b5643b 100644 --- a/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml +++ b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml @@ -4,3 +4,11 @@ # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) - platform: non_adr_0007 no_such_option: abc + option1: abc +# This violates the non_adr_0007.iot_domain platform schema: +# - no_such_option does not exist +# - option1 is missing +# - option2 is wrong type +- platform: non_adr_0007 + no_such_option: abc + option2: 123 diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml index cd1aa71ef3f..dff25efd749 100644 --- a/tests/fixtures/core/config/component_validation/packages/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -19,6 +19,16 @@ homeassistant: # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) - platform: non_adr_0007 no_such_option: abc + option1: abc + pack_iot_domain_5: + iot_domain: + # This violates the non_adr_0007.iot_domain platform schema: + # - no_such_option does not exist + # - option1 is missing + # - option2 is wrong type + - platform: non_adr_0007 + no_such_option: abc + option2: 123 pack_adr_0007_1: # This is correct and should not generate errors adr_0007_1: @@ -36,3 +46,11 @@ homeassistant: adr_0007_4: host: blah.com no_such_option: foo + pack_adr_0007_5: + # Multiple errors: + # - host is missing + # - no_such_option does not exist + # - port is wrong type + adr_0007_5: + no_such_option: foo + port: foo diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml new file mode 100644 index 00000000000..fad2c53d527 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml @@ -0,0 +1,7 @@ +# Multiple errors: +# - host is missing +# - no_such_option does not exist +# - port is wrong type +adr_0007_5: + no_such_option: foo + port: foo diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml index bf18c0f74cf..e137411b0fc 100644 --- a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml @@ -9,4 +9,12 @@ iot_domain: option1: 123 # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) - platform: non_adr_0007 - - no_such_option: abc + no_such_option: abc + option1: abc + # This violates the non_adr_0007.iot_domain platform schema: + # - no_such_option does not exist + # - option1 is missing + # - option2 is wrong type + - platform: non_adr_0007 + no_such_option: abc + option2: 123 diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 33b5e193d87..af4374e25f9 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -4,9 +4,11 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: required key not provided 'adr_0007_2->host', got None.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 24: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", - "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 29: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", ]) # --- # name: test_component_config_validation_error[basic_include] @@ -14,8 +16,11 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key not provided 'adr_0007_2->host', got None.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", ]) # --- # name: test_component_config_validation_error[include_dir_list] @@ -23,6 +28,7 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] @@ -30,6 +36,7 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ]) # --- # name: test_component_config_validation_error[packages] @@ -37,19 +44,23 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 28: required key not provided 'adr_0007_2->host', got None.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 33: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", - "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 48: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: required key not provided 'platform', got None.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key not provided 'adr_0007_2->host', got None.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", ]) # --- # name: test_package_merge_error[packages] diff --git a/tests/test_config.py b/tests/test_config.py index 56861057526..fb88e8ca3a2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -121,7 +121,9 @@ async def mock_non_adr_0007_integration(hass) -> None: configuration key """ - test_platform_schema = IOT_DOMAIN_PLATFORM_SCHEMA.extend({"option1": str}) + test_platform_schema = IOT_DOMAIN_PLATFORM_SCHEMA.extend( + {vol.Required("option1"): str, vol.Optional("option2"): str} + ) mock_platform( hass, "non_adr_0007.iot_domain", @@ -133,7 +135,13 @@ async def mock_non_adr_0007_integration(hass) -> None: async def mock_adr_0007_integrations(hass) -> list[Integration]: """Mock ADR-0007 compliant integrations.""" integrations = [] - for domain in ["adr_0007_1", "adr_0007_2", "adr_0007_3", "adr_0007_4"]: + for domain in [ + "adr_0007_1", + "adr_0007_2", + "adr_0007_3", + "adr_0007_4", + "adr_0007_5", + ]: adr_0007_config_schema = vol.Schema( { domain: vol.Schema( @@ -1504,6 +1512,7 @@ async def test_component_config_validation_error( "adr_0007_2", "adr_0007_3", "adr_0007_4", + "adr_0007_5", ]: integration = await async_get_integration(hass, domain) await config_util.async_process_component_config( From 4465c74d2309865b06e3d16cd392af0a3c4cf02a Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 14 Nov 2023 16:05:08 +0100 Subject: [PATCH 470/982] Add broadlink climate (#91183) --- .coveragerc | 1 + CODEOWNERS | 4 +- homeassistant/components/broadlink/climate.py | 85 +++++++++++++++++++ homeassistant/components/broadlink/const.py | 1 + .../components/broadlink/manifest.json | 5 +- homeassistant/components/broadlink/updater.py | 9 ++ homeassistant/generated/dhcp.py | 4 + 7 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/broadlink/climate.py diff --git a/.coveragerc b/.coveragerc index 04c9182e0df..d6809f4301d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -144,6 +144,7 @@ omit = homeassistant/components/braviatv/coordinator.py homeassistant/components/braviatv/media_player.py homeassistant/components/braviatv/remote.py + homeassistant/components/broadlink/climate.py homeassistant/components/broadlink/light.py homeassistant/components/broadlink/remote.py homeassistant/components/broadlink/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 6321847b41a..20f0e67f74e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -170,8 +170,8 @@ build.json @home-assistant/supervisor /tests/components/bosch_shc/ @tschamm /homeassistant/components/braviatv/ @bieniu @Drafteed /tests/components/braviatv/ @bieniu @Drafteed -/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am -/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am +/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger +/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger /homeassistant/components/brother/ @bieniu /tests/components/brother/ @bieniu /homeassistant/components/brottsplatskartan/ @gjohansson-ST diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py new file mode 100644 index 00000000000..6937d6bb0da --- /dev/null +++ b/homeassistant/components/broadlink/climate.py @@ -0,0 +1,85 @@ +"""Support for Broadlink climate devices.""" +from typing import Any + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PRECISION_HALVES, Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, DOMAINS_AND_TYPES +from .device import BroadlinkDevice +from .entity import BroadlinkEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Broadlink climate entities.""" + device = hass.data[DOMAIN].devices[config_entry.entry_id] + + if device.api.type in DOMAINS_AND_TYPES[Platform.CLIMATE]: + async_add_entities([BroadlinkThermostat(device)]) + + +class BroadlinkThermostat(ClimateEntity, BroadlinkEntity): + """Representation of a Broadlink Hysen climate entity.""" + + _attr_has_entity_name = True + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, device: BroadlinkDevice) -> None: + """Initialize the climate entity.""" + super().__init__(device) + self._attr_unique_id = device.unique_id + self._attr_hvac_mode = None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + await self._device.async_request(self._device.api.set_temp, temperature) + self._attr_target_temperature = temperature + self.async_write_ha_state() + + @callback + def _update_state(self, data: dict[str, Any]) -> None: + """Update data.""" + if data.get("power"): + if data.get("auto_mode"): + self._attr_hvac_mode = HVACMode.AUTO + else: + self._attr_hvac_mode = HVACMode.HEAT + + if data.get("active"): + self._attr_hvac_action = HVACAction.HEATING + else: + self._attr_hvac_action = HVACAction.IDLE + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_action = HVACAction.OFF + + self._attr_current_temperature = data.get("room_temp") + self._attr_target_temperature = data.get("thermostat_temp") + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF: + await self._device.async_request(self._device.api.set_power, 0) + else: + await self._device.async_request(self._device.api.set_power, 1) + mode = 0 if hvac_mode == HVACMode.HEAT else 1 + await self._device.async_request(self._device.api.set_mode, mode, 0) + + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index c1ccc5ec954..2b9e8787a43 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -4,6 +4,7 @@ from homeassistant.const import Platform DOMAIN = "broadlink" DOMAINS_AND_TYPES = { + Platform.CLIMATE: {"HYS"}, Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, Platform.SENSOR: { "A1", diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 5778520e530..7fd925a2ff4 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -1,7 +1,7 @@ { "domain": "broadlink", "name": "Broadlink", - "codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"], + "codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"], "config_flow": true, "dhcp": [ { @@ -30,6 +30,9 @@ }, { "macaddress": "EC0BAE*" + }, + { + "macaddress": "780F77*" } ], "documentation": "https://www.home-assistant.io/integrations/broadlink", diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index da8461bf90f..10ac4df4bb8 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -16,6 +16,7 @@ def get_update_manager(device): update_managers = { "A1": BroadlinkA1UpdateManager, "BG1": BroadlinkBG1UpdateManager, + "HYS": BroadlinkThermostatUpdateManager, "LB1": BroadlinkLB1UpdateManager, "LB2": BroadlinkLB1UpdateManager, "MP1": BroadlinkMP1UpdateManager, @@ -184,3 +185,11 @@ class BroadlinkLB1UpdateManager(BroadlinkUpdateManager): async def async_fetch_data(self): """Fetch data from the device.""" return await self.device.async_request(self.device.api.get_state) + + +class BroadlinkThermostatUpdateManager(BroadlinkUpdateManager): + """Manages updates for thermostats with Broadlink DNA.""" + + async def async_fetch_data(self): + """Fetch data from the device.""" + return await self.device.async_request(self.device.api.get_full_status) diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 63c7cd84303..6d04d7602f2 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -109,6 +109,10 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "broadlink", "macaddress": "EC0BAE*", }, + { + "domain": "broadlink", + "macaddress": "780F77*", + }, { "domain": "dlink", "hostname": "dsp-w215", From c621c5df39f33dc98fe0351612a2e5f773e90a78 Mon Sep 17 00:00:00 2001 From: Chris Straffon Date: Tue, 14 Nov 2023 15:40:55 +0000 Subject: [PATCH 471/982] Removed codeowner for growatt_server (#103970) --- CODEOWNERS | 2 -- homeassistant/components/growatt_server/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 20f0e67f74e..1be408045cc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -490,8 +490,6 @@ build.json @home-assistant/supervisor /tests/components/greeneye_monitor/ @jkeljo /homeassistant/components/group/ @home-assistant/core /tests/components/group/ @home-assistant/core -/homeassistant/components/growatt_server/ @muppet3000 -/tests/components/growatt_server/ @muppet3000 /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya /homeassistant/components/habitica/ @ASMfreaK @leikoilja diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index a21c811af47..d872474f1da 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -1,7 +1,7 @@ { "domain": "growatt_server", "name": "Growatt", - "codeowners": ["@muppet3000"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", From b35afab5efdb67945f5f4fc16e7eda99449752ef Mon Sep 17 00:00:00 2001 From: Kalpit <14352316+TheKalpit@users.noreply.github.com> Date: Tue, 14 Nov 2023 21:11:13 +0530 Subject: [PATCH 472/982] Add reply_to_message_id to all telegram_bot message types (#103566) --- homeassistant/components/telegram_bot/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 76677c3813e..7d150e95977 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -786,6 +786,7 @@ class TelegramNotificationService: photo=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], @@ -799,6 +800,7 @@ class TelegramNotificationService: chat_id=chat_id, sticker=file_content, disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], ) @@ -812,6 +814,7 @@ class TelegramNotificationService: video=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], @@ -825,6 +828,7 @@ class TelegramNotificationService: document=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], @@ -838,6 +842,7 @@ class TelegramNotificationService: voice=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], ) @@ -850,6 +855,7 @@ class TelegramNotificationService: animation=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], @@ -872,6 +878,7 @@ class TelegramNotificationService: chat_id=chat_id, sticker=stickerid, disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], ) @@ -895,6 +902,7 @@ class TelegramNotificationService: latitude=latitude, longitude=longitude, disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], timeout=params[ATTR_TIMEOUT], ) @@ -923,6 +931,7 @@ class TelegramNotificationService: allows_multiple_answers=allows_multiple_answers, open_period=openperiod, disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], timeout=params[ATTR_TIMEOUT], ) From 7f08f139d60c5f6e77c0a7af7ef8164e2256f605 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 14 Nov 2023 17:07:27 +0100 Subject: [PATCH 473/982] Fix openexchangerates form data description (#103974) --- homeassistant/components/openexchangerates/config_flow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index a61264dbf41..b78227ed1e5 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -66,7 +66,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._reauth_entry.data if self._reauth_entry else {} ) return self.async_show_form( - step_id="user", data_schema=get_data_schema(currencies, existing_data) + step_id="user", + data_schema=get_data_schema(currencies, existing_data), + description_placeholders={ + "signup": "https://openexchangerates.org/signup" + }, ) errors = {} From aca48b5e45d7ff9702c894669d589933d18a91d0 Mon Sep 17 00:00:00 2001 From: Chuck Foster <75957355+fosterchuck@users.noreply.github.com> Date: Tue, 14 Nov 2023 08:13:14 -0800 Subject: [PATCH 474/982] Fix duplicate Ban file entries (#103953) --- homeassistant/components/http/ban.py | 5 +++-- tests/components/http/test_ban.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 89d927ee8af..c56dd6c343b 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -243,5 +243,6 @@ class IpBanManager: async def async_add_ban(self, remote_addr: IPv4Address | IPv6Address) -> None: """Add a new IP address to the banned list.""" - new_ban = self.ip_bans_lookup[remote_addr] = IpBan(remote_addr) - await self.hass.async_add_executor_job(self._add_ban, new_ban) + if remote_addr not in self.ip_bans_lookup: + new_ban = self.ip_bans_lookup[remote_addr] = IpBan(remote_addr) + await self.hass.async_add_executor_job(self._add_ban, new_ban) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 8082a268a80..d1123a7009e 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -392,3 +392,29 @@ async def test_failed_login_attempts_counter( resp = await client.get("/auth_false") assert resp.status == HTTPStatus.UNAUTHORIZED assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + +async def test_single_ban_file_entry( + hass: HomeAssistant, +) -> None: + """Test that only one item is added to ban file.""" + app = web.Application() + app["hass"] = hass + + async def unauth_handler(request): + """Return a mock web response.""" + raise HTTPUnauthorized + + app.router.add_get("/example", unauth_handler) + setup_bans(hass, app, 2) + mock_real_ip(app)("200.201.202.204") + + manager: IpBanManager = app[KEY_BAN_MANAGER] + m_open = mock_open() + + with patch("homeassistant.components.http.ban.open", m_open, create=True): + remote_ip = ip_address("200.201.202.204") + await manager.async_add_ban(remote_ip) + await manager.async_add_ban(remote_ip) + + assert m_open.call_count == 1 From 2cb4435cf0aa6858cf25429ce881cdf92ced87a5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Nov 2023 17:14:34 +0100 Subject: [PATCH 475/982] Add tests for component configuration with documentation links (#103971) --- tests/snapshots/test_config.ambr | 12 +++ tests/test_config.py | 135 ++++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index af4374e25f9..87d2af1e755 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -63,6 +63,18 @@ "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", ]) # --- +# name: test_component_config_validation_error_with_docs[basic] + list([ + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided 'platform', got None. Please check the docs at https://www.home-assistant.io/integrations/iot_domain", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key not provided 'adr_0007_2->host', got None. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_2", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_3", + "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option Please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", + "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5", + ]) +# --- # name: test_package_merge_error[packages] list([ 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages/configuration.yaml:9).', diff --git a/tests/test_config.py b/tests/test_config.py index fb88e8ca3a2..ca6fb3b52bc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -114,7 +114,26 @@ async def mock_iot_domain_integration(hass: HomeAssistant) -> Integration: @pytest.fixture -async def mock_non_adr_0007_integration(hass) -> None: +async def mock_iot_domain_integration_with_docs(hass: HomeAssistant) -> Integration: + """Mock an integration which provides an IoT domain.""" + comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) + + return mock_integration( + hass, + MockModule( + "iot_domain", + platform_schema_base=comp_platform_schema_base, + platform_schema=comp_platform_schema, + partial_manifest={ + "documentation": "https://www.home-assistant.io/integrations/iot_domain" + }, + ), + ) + + +@pytest.fixture +async def mock_non_adr_0007_integration(hass: HomeAssistant) -> None: """Mock a non-ADR-0007 compliant integration with iot_domain platform. The integration allows setting up iot_domain entities under the iot_domain's @@ -132,7 +151,34 @@ async def mock_non_adr_0007_integration(hass) -> None: @pytest.fixture -async def mock_adr_0007_integrations(hass) -> list[Integration]: +async def mock_non_adr_0007_integration_with_docs(hass: HomeAssistant) -> None: + """Mock a non-ADR-0007 compliant integration with iot_domain platform. + + The integration allows setting up iot_domain entities under the iot_domain's + configuration key + """ + + mock_integration( + hass, + MockModule( + "non_adr_0007", + partial_manifest={ + "documentation": "https://www.home-assistant.io/integrations/non_adr_0007" + }, + ), + ) + test_platform_schema = IOT_DOMAIN_PLATFORM_SCHEMA.extend( + {vol.Required("option1"): str, vol.Optional("option2"): str} + ) + mock_platform( + hass, + "non_adr_0007.iot_domain", + MockPlatform(platform_schema=test_platform_schema), + ) + + +@pytest.fixture +async def mock_adr_0007_integrations(hass: HomeAssistant) -> list[Integration]: """Mock ADR-0007 compliant integrations.""" integrations = [] for domain in [ @@ -162,6 +208,45 @@ async def mock_adr_0007_integrations(hass) -> list[Integration]: return integrations +@pytest.fixture +async def mock_adr_0007_integrations_with_docs( + hass: HomeAssistant, +) -> list[Integration]: + """Mock ADR-0007 compliant integrations.""" + integrations = [] + for domain in [ + "adr_0007_1", + "adr_0007_2", + "adr_0007_3", + "adr_0007_4", + "adr_0007_5", + ]: + adr_0007_config_schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Required("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + integrations.append( + mock_integration( + hass, + MockModule( + domain, + config_schema=adr_0007_config_schema, + partial_manifest={ + "documentation": f"https://www.home-assistant.io/integrations/{domain}" + }, + ), + ) + ) + return integrations + + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" assert not os.path.isfile(YAML_PATH) @@ -1529,6 +1614,52 @@ async def test_component_config_validation_error( assert error_records == snapshot +@pytest.mark.parametrize( + "config_dir", + [ + "basic", + ], +) +async def test_component_config_validation_error_with_docs( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + mock_iot_domain_integration_with_docs: Integration, + mock_non_adr_0007_integration_with_docs: None, + mock_adr_0007_integrations_with_docs: list[Integration], + snapshot: SnapshotAssertion, +) -> None: + """Test schema error in component.""" + + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "component_validation", config_dir + ) + config = await config_util.async_hass_config_yaml(hass) + + for domain in [ + "iot_domain", + "adr_0007_1", + "adr_0007_2", + "adr_0007_3", + "adr_0007_4", + "adr_0007_5", + ]: + integration = await async_get_integration(hass, domain) + await config_util.async_process_component_config( + hass, + config, + integration=integration, + ) + + error_records = [ + record.message.replace(base_path, "") + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot + + @pytest.mark.parametrize( "config_dir", ["packages", "packages_include_dir_named"], From be8507f8706e22c23f241de45bd37f2f0a022639 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 14 Nov 2023 12:00:30 -0600 Subject: [PATCH 476/982] Add HassListAddItem intent (#103716) * Add HassListAddItem intent * Add missing list test --- homeassistant/components/todo/intent.py | 54 +++++++++++++++++ tests/components/todo/test_init.py | 81 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 homeassistant/components/todo/intent.py diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py new file mode 100644 index 00000000000..ba3545d8dfd --- /dev/null +++ b/homeassistant/components/todo/intent.py @@ -0,0 +1,54 @@ +"""Intents for the todo integration.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent + +from . import DOMAIN, TodoItem, TodoListEntity + +INTENT_LIST_ADD_ITEM = "HassListAddItem" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the todo intents.""" + intent.async_register(hass, ListAddItemIntent()) + + +class ListAddItemIntent(intent.IntentHandler): + """Handle ListAddItem intents.""" + + intent_type = INTENT_LIST_ADD_ITEM + slot_schema = {"item": cv.string, "name": cv.string} + + async def async_handle(self, intent_obj: intent.Intent): + """Handle the intent.""" + hass = intent_obj.hass + + slots = self.async_validate_slots(intent_obj.slots) + item = slots["item"]["value"] + list_name = slots["name"]["value"] + + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + target_list: TodoListEntity | None = None + + # Find matching list + for list_state in intent.async_match_states( + hass, name=list_name, domains=[DOMAIN] + ): + target_list = component.get_entity(list_state.entity_id) + if target_list is not None: + break + + if target_list is None: + raise intent.IntentHandleError(f"No to-do list: {list_name}") + + assert target_list is not None + + # Add to list + await target_list.async_create_todo_item(TodoItem(item)) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + return response diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 3e84049efa8..33f9af2b0c5 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -13,11 +13,13 @@ from homeassistant.components.todo import ( TodoItemStatus, TodoListEntity, TodoListEntityFeature, + intent as todo_intent, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import ( @@ -37,6 +39,18 @@ class MockFlow(ConfigFlow): """Test flow.""" +class MockTodoListEntity(TodoListEntity): + """Test todo list entity.""" + + def __init__(self) -> None: + """Initialize entity.""" + self.items: list[TodoItem] = [] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + self.items.append(item) + + @pytest.fixture(autouse=True) def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: """Mock config flow.""" @@ -737,3 +751,70 @@ async def test_move_item_unsupported( resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("error", {}).get("code") == "not_supported" + + +async def test_add_item_intent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test adding items to lists using an intent.""" + await todo_intent.async_setup_intents(hass) + + entity1 = MockTodoListEntity() + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + entity2 = MockTodoListEntity() + entity2._attr_name = "List 2" + entity2.entity_id = "todo.list_2" + + await create_mock_platform(hass, [entity1, entity2]) + + # Add to first list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "beer"}, "name": {"value": "list 1"}}, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 1 + assert len(entity2.items) == 0 + assert entity1.items[0].summary == "beer" + entity1.items.clear() + + # Add to second list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "cheese"}, "name": {"value": "List 2"}}, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 1 + assert entity2.items[0].summary == "cheese" + + # List name is case insensitive + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": "lIST 2"}}, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 2 + assert entity2.items[1].summary == "wine" + + # Missing list + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, + ) From 9facdc2dbb0893b52560e86a222cca6090fb515f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 14 Nov 2023 19:01:45 +0100 Subject: [PATCH 477/982] Remove openexchangerates sensor rounding (#103972) --- homeassistant/components/openexchangerates/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 70f2f670de8..66baf54c16a 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -64,4 +64,4 @@ class OpenexchangeratesSensor( @property def native_value(self) -> float: """Return the state of the sensor.""" - return round(self.coordinator.data.rates[self._quote], 4) + return self.coordinator.data.rates[self._quote] From e87ebbef01fbe1bf869f6719286088d59f0ed33c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Nov 2023 21:50:54 +0100 Subject: [PATCH 478/982] Improve errors for component configuration with multiple errors (#103969) * Improve errors for component configuration with multiple errors * Suffix with link to documentation --- homeassistant/config.py | 73 ++++++++++++++++----------- tests/snapshots/test_config.ambr | 84 +++++++++++++++++++++++++------- 2 files changed, 110 insertions(+), 47 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 569c059e9c2..d39267f6f7d 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -578,26 +578,57 @@ def find_annotation( return find_annotation_rec(config, list(path), None) -def stringify_invalid(ex: vol.Invalid) -> str: +def stringify_invalid( + ex: vol.Invalid, + domain: str, + config: dict, + link: str | None, + max_sub_error_length: int, +) -> str: """Stringify voluptuous.Invalid. This is an alternative to the custom __str__ implemented in - voluptuous.error.Invalid. The main modification is to format - the path delimited by -> instead of @data[]. + voluptuous.error.Invalid. The modifications are: + - Format the path delimited by -> instead of @data[] + - Prefix with domain, file and line of the error + - Suffix with a link to the documentation + - Give a more user friendly output for unknown options """ + message_prefix = f"Invalid config for [{domain}]" + if domain != CONF_CORE and link: + message_suffix = f". Please check the docs at {link}" + else: + message_suffix = "" + if annotation := find_annotation(config, ex.path): + message_prefix += f" at {annotation[0]}, line {annotation[1]}" path = "->".join(str(m) for m in ex.path) + if ex.error_message == "extra keys not allowed": + return ( + f"{message_prefix}: '{ex.path[-1]}' is an invalid option for [{domain}], " + f"check: {path}{message_suffix}" + ) # This function is an alternative to the stringification done by # vol.Invalid.__str__, so we need to call Exception.__str__ here # instead of str(ex) output = Exception.__str__(ex) if error_type := ex.error_type: output += " for " + error_type - return f"{output} '{path}'" + offending_item_summary = repr(_get_by_path(config, ex.path)) + if len(offending_item_summary) > max_sub_error_length: + offending_item_summary = ( + f"{offending_item_summary[: max_sub_error_length - 3]}..." + ) + return ( + f"{message_prefix}: {output} '{path}', got {offending_item_summary}" + f"{message_suffix}." + ) def humanize_error( - data: Any, validation_error: vol.Invalid, + domain: str, + config: dict, + link: str | None, max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH, ) -> str: """Provide a more helpful + complete validation error message. @@ -608,16 +639,13 @@ def humanize_error( if isinstance(validation_error, vol.MultipleInvalid): return "\n".join( sorted( - humanize_error(data, sub_error, max_sub_error_length) + humanize_error(sub_error, domain, config, link, max_sub_error_length) for sub_error in validation_error.errors ) ) - offending_item_summary = repr(_get_by_path(data, validation_error.path)) - if len(offending_item_summary) > max_sub_error_length: - offending_item_summary = ( - f"{offending_item_summary[: max_sub_error_length - 3]}..." - ) - return f"{stringify_invalid(validation_error)}, got {offending_item_summary}" + return stringify_invalid( + validation_error, domain, config, link, max_sub_error_length + ) @callback @@ -629,28 +657,15 @@ def _format_config_error( This method must be run in the event loop. """ is_friendly = False - message = f"Invalid config for [{domain}]" if isinstance(ex, vol.Invalid): - if annotation := find_annotation(config, ex.path): - message += f" at {annotation[0]}, line {annotation[1]}: " - else: - message += ": " - - if "extra keys not allowed" in ex.error_message: - path = "->".join(str(m) for m in ex.path) - message += ( - f"'{ex.path[-1]}' is an invalid option for [{domain}], check: {path}" - ) - else: - message += f"{humanize_error(config, ex)}." + message = humanize_error(ex, domain, config, link) is_friendly = True else: - message += ": " - message += str(ex) or repr(ex) + message = f"Invalid config for [{domain}]: {str(ex) or repr(ex)}" - if domain != CONF_CORE and link: - message += f" Please check the docs at {link}" + if domain != CONF_CORE and link: + message += f" Please check the docs at {link}." return message, is_friendly diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 87d2af1e755..fcd3927a891 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -4,11 +4,19 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. + ''', "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key not provided 'adr_0007_2->host', got None.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", - "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", + ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key not provided 'adr_0007_5->host', got None. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + ''', ]) # --- # name: test_component_config_validation_error[basic_include] @@ -16,11 +24,19 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 17: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123. + ''', "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key not provided 'adr_0007_2->host', got None.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", - "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", + ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 6: required key not provided 'adr_0007_5->host', got None. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + ''', ]) # --- # name: test_component_config_validation_error[include_dir_list] @@ -28,7 +44,11 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 5: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123. + ''', ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] @@ -36,7 +56,11 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 12: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123. + ''', ]) # --- # name: test_component_config_validation_error[packages] @@ -44,11 +68,19 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 29: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 31: expected str for dictionary value 'option2', got 123. + ''', "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: required key not provided 'adr_0007_2->host', got None.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 48: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", - "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", + ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 54: required key not provided 'adr_0007_5->host', got None. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + ''', ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] @@ -56,23 +88,39 @@ "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key not provided 'platform', got None.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 18: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123. + ''', "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key not provided 'adr_0007_2->host', got None.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", - "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option", + ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 5: required key not provided 'adr_0007_5->host', got None. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + ''', ]) # --- # name: test_component_config_validation_error_with_docs[basic] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided 'platform', got None. Please check the docs at https://www.home-assistant.io/integrations/iot_domain", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key not provided 'adr_0007_2->host', got None. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_2", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_3", - "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option Please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", - "Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided 'platform', got None. Please check the docs at https://www.home-assistant.io/integrations/iot_domain.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007.", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key not provided 'option1', got None. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. + ''', + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key not provided 'adr_0007_2->host', got None. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_2.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_3.", + "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", + ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key not provided 'adr_0007_5->host', got None. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. + ''', ]) # --- # name: test_package_merge_error[packages] From 54c98f32c2bffdf9ae561b225d28a6e9068cf8f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Nov 2023 18:02:14 -0600 Subject: [PATCH 479/982] Bump aiohttp to 3.9.0rc0 for python 3.12 only (#103507) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/helpers/aiohttp_client.py | 13 ------------- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/test_util/aiohttp.py | 6 ++++++ 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index b8d810d899b..fba9bb647dd 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -58,19 +58,6 @@ MAXIMUM_CONNECTIONS = 4096 MAXIMUM_CONNECTIONS_PER_HOST = 100 -# Overwrite base aiohttp _wait implementation -# Homeassistant has a custom shutdown wait logic. -async def _noop_wait(*args: Any, **kwargs: Any) -> None: - """Do nothing.""" - return - - -# TODO: Remove version check with aiohttp 3.9.0 # pylint: disable=fixme -if sys.version_info >= (3, 12): - # pylint: disable-next=protected-access - web.BaseSite._wait = _noop_wait # type: ignore[method-assign] - - class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index eaf7dc0dadd..04ab96748d2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.5.1 aiohttp-fast-url-dispatcher==0.1.0 aiohttp-zlib-ng==0.1.1 aiohttp==3.8.5;python_version<'3.12' -aiohttp==3.9.0b0;python_version>='3.12' +aiohttp==3.9.0rc0;python_version>='3.12' aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.2 diff --git a/pyproject.toml b/pyproject.toml index 550cafc4146..67657586ea5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.9.0b0;python_version>='3.12'", + "aiohttp==3.9.0rc0;python_version>='3.12'", "aiohttp==3.8.5;python_version<'3.12'", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.1.0", diff --git a/requirements.txt b/requirements.txt index f751354a4a3..5b33374b7cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.9.0b0;python_version>='3.12' +aiohttp==3.9.0rc0;python_version>='3.12' aiohttp==3.8.5;python_version<'3.12' aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.1.0 diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index ac874fcc45c..4f2518253ff 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -280,6 +280,12 @@ class AiohttpClientMockResponse: def close(self): """Mock close.""" + async def wait_for_close(self): + """Wait until all requests are done. + + Do nothing as we are mocking. + """ + @property def response(self): """Property method to expose the response to other read methods.""" From 17f0676483f6622eeffc9a41d9db83281edc0882 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:47:39 +0100 Subject: [PATCH 480/982] Remove Plugwise entity descriptions required fields mixins (#104004) --- homeassistant/components/plugwise/number.py | 14 +++----------- homeassistant/components/plugwise/select.py | 16 ++++------------ 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 9865aec2242..2c87edddf04 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -23,19 +23,11 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass -class PlugwiseEntityDescriptionMixin: - """Mixin values for Plugwise entities.""" - - command: Callable[[Smile, str, str, float], Awaitable[None]] - - -@dataclass -class PlugwiseNumberEntityDescription( - NumberEntityDescription, PlugwiseEntityDescriptionMixin -): +@dataclass(kw_only=True) +class PlugwiseNumberEntityDescription(NumberEntityDescription): """Class describing Plugwise Number entities.""" + command: Callable[[Smile, str, str, float], Awaitable[None]] key: NumberType diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 138e5fe3b59..c12ca671554 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -18,21 +18,13 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass -class PlugwiseSelectDescriptionMixin: - """Mixin values for Plugwise Select entities.""" - - command: Callable[[Smile, str, str], Awaitable[None]] - options_key: SelectOptionsType - - -@dataclass -class PlugwiseSelectEntityDescription( - SelectEntityDescription, PlugwiseSelectDescriptionMixin -): +@dataclass(kw_only=True) +class PlugwiseSelectEntityDescription(SelectEntityDescription): """Class describing Plugwise Select entities.""" + command: Callable[[Smile, str, str], Awaitable[None]] key: SelectType + options_key: SelectOptionsType SELECT_TYPES = ( From ce1e6ce006e9f37a88b3231089ff33ab20f0205f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:48:01 +0100 Subject: [PATCH 481/982] Remove DSMR entity descriptions required fields mixins (#104002) --- homeassistant/components/dsmr/sensor.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index fa58bd8c5a6..d4dfde274d1 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -67,21 +67,13 @@ EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}" UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS} -@dataclass -class DSMRSensorEntityDescriptionMixin: - """Mixin for required keys.""" - - obis_reference: str - - -@dataclass -class DSMRSensorEntityDescription( - SensorEntityDescription, DSMRSensorEntityDescriptionMixin -): +@dataclass(kw_only=True) +class DSMRSensorEntityDescription(SensorEntityDescription): """Represents an DSMR Sensor.""" dsmr_versions: set[str] | None = None is_gas: bool = False + obis_reference: str SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( From 1a079d7c6f75fa38720f2e706e02b871246c59fd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:48:20 +0100 Subject: [PATCH 482/982] Remove LaMetric entity descriptions required fields mixins (#104001) --- homeassistant/components/lametric/button.py | 13 +++---------- homeassistant/components/lametric/number.py | 13 +++---------- homeassistant/components/lametric/select.py | 13 +++---------- homeassistant/components/lametric/sensor.py | 13 +++---------- homeassistant/components/lametric/switch.py | 16 ++++------------ 5 files changed, 16 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 18a0c2f8f72..1de8c1d1717 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -19,20 +19,13 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass -class LaMetricButtonEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(kw_only=True) +class LaMetricButtonEntityDescription(ButtonEntityDescription): + """Class describing LaMetric button entities.""" press_fn: Callable[[LaMetricDevice], Awaitable[Any]] -@dataclass -class LaMetricButtonEntityDescription( - ButtonEntityDescription, LaMetricButtonEntityDescriptionMixin -): - """Class describing LaMetric button entities.""" - - BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index da458cab61e..d8c70494264 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -19,21 +19,14 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(kw_only=True) +class LaMetricNumberEntityDescription(NumberEntityDescription): + """Class describing LaMetric number entities.""" value_fn: Callable[[Device], int | None] set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] -@dataclass -class LaMetricNumberEntityDescription( - NumberEntityDescription, LaMetricEntityDescriptionMixin -): - """Class describing LaMetric number entities.""" - - NUMBERS = [ LaMetricNumberEntityDescription( key="brightness", diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index b7c0e55745e..f15147235ac 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -19,21 +19,14 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(kw_only=True) +class LaMetricSelectEntityDescription(SelectEntityDescription): + """Class describing LaMetric select entities.""" current_fn: Callable[[Device], str] select_fn: Callable[[LaMetricDevice, str], Awaitable[Any]] -@dataclass -class LaMetricSelectEntityDescription( - SelectEntityDescription, LaMetricEntityDescriptionMixin -): - """Class describing LaMetric select entities.""" - - SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index 6cddf81b2bf..88d461e9d4f 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -21,20 +21,13 @@ from .coordinator import LaMetricDataUpdateCoordinator from .entity import LaMetricEntity -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(kw_only=True) +class LaMetricSensorEntityDescription(SensorEntityDescription): + """Class describing LaMetric sensor entities.""" value_fn: Callable[[Device], int | None] -@dataclass -class LaMetricSensorEntityDescription( - SensorEntityDescription, LaMetricEntityDescriptionMixin -): - """Class describing LaMetric sensor entities.""" - - SENSORS = [ LaMetricSensorEntityDescription( key="rssi", diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index c33ec16d617..ace492fe0cb 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -19,21 +19,13 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" - - is_on_fn: Callable[[Device], bool] - set_fn: Callable[[LaMetricDevice, bool], Awaitable[Any]] - - -@dataclass -class LaMetricSwitchEntityDescription( - SwitchEntityDescription, LaMetricEntityDescriptionMixin -): +@dataclass(kw_only=True) +class LaMetricSwitchEntityDescription(SwitchEntityDescription): """Class describing LaMetric switch entities.""" available_fn: Callable[[Device], bool] = lambda device: True + is_on_fn: Callable[[Device], bool] + set_fn: Callable[[LaMetricDevice, bool], Awaitable[Any]] SWITCHES = [ From 76ccad40ff1c86b3ce4aa0ceb1d9b8ce7f941179 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:48:31 +0100 Subject: [PATCH 483/982] Remove Rituals Parfume Genie entity descriptions required fields mixins (#103999) --- .../rituals_perfume_genie/binary_sensor.py | 13 +++---------- .../components/rituals_perfume_genie/number.py | 13 +++---------- .../components/rituals_perfume_genie/select.py | 13 +++---------- .../components/rituals_perfume_genie/sensor.py | 14 +++----------- 4 files changed, 12 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 73499fb5ccc..ab13898394c 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -21,21 +21,14 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass -class RitualsentityDescriptionMixin: - """Mixin values for Rituals entities.""" +@dataclass(kw_only=True) +class RitualsBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Rituals binary sensor entities.""" is_on_fn: Callable[[Diffuser], bool] has_fn: Callable[[Diffuser], bool] -@dataclass -class RitualsBinarySensorEntityDescription( - BinarySensorEntityDescription, RitualsentityDescriptionMixin -): - """Class describing Rituals binary sensor entities.""" - - ENTITY_DESCRIPTIONS = ( RitualsBinarySensorEntityDescription( key="charging", diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 3e6af33315f..35b5a3bd008 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -17,21 +17,14 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass -class RitualsNumberEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class RitualsNumberEntityDescription(NumberEntityDescription): + """Class describing Rituals number entities.""" value_fn: Callable[[Diffuser], int] set_value_fn: Callable[[Diffuser, int], Awaitable[Any]] -@dataclass -class RitualsNumberEntityDescription( - NumberEntityDescription, RitualsNumberEntityDescriptionMixin -): - """Class describing Rituals number entities.""" - - ENTITY_DESCRIPTIONS = ( RitualsNumberEntityDescription( key="perfume_amount", diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 42e18624d13..2126ecb147f 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -17,21 +17,14 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass -class RitualsEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class RitualsSelectEntityDescription(SelectEntityDescription): + """Class describing Rituals select entities.""" current_fn: Callable[[Diffuser], str] select_fn: Callable[[Diffuser, str], Awaitable[None]] -@dataclass -class RitualsSelectEntityDescription( - SelectEntityDescription, RitualsEntityDescriptionMixin -): - """Class describing Rituals select entities.""" - - ENTITY_DESCRIPTIONS = ( RitualsSelectEntityDescription( key="room_size_square_meter", diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 09189dabfad..5f7ae45d330 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -21,20 +21,12 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass -class RitualsEntityDescriptionMixin: - """Mixin values for Rituals entities.""" - - value_fn: Callable[[Diffuser], int | str] - - -@dataclass -class RitualsSensorEntityDescription( - SensorEntityDescription, RitualsEntityDescriptionMixin -): +@dataclass(kw_only=True) +class RitualsSensorEntityDescription(SensorEntityDescription): """Class describing Rituals sensor entities.""" has_fn: Callable[[Diffuser], bool] = lambda _: True + value_fn: Callable[[Diffuser], int | str] ENTITY_DESCRIPTIONS = ( From 599579b26dafeed00f60fe8c947ecfd2bc77a273 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:48:49 +0100 Subject: [PATCH 484/982] Remove Tailscale entity descriptions required fields mixins (#103998) Remove Tailsale entity descriptions required fields mixins --- homeassistant/components/tailscale/binary_sensor.py | 13 +++---------- homeassistant/components/tailscale/sensor.py | 13 +++---------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index ecc561f0355..ee1c682c559 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -20,20 +20,13 @@ from . import TailscaleEntity from .const import DOMAIN -@dataclass -class TailscaleBinarySensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class TailscaleBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Tailscale binary sensor entity.""" is_on_fn: Callable[[TailscaleDevice], bool | None] -@dataclass -class TailscaleBinarySensorEntityDescription( - BinarySensorEntityDescription, TailscaleBinarySensorEntityDescriptionMixin -): - """Describes a Tailscale binary sensor entity.""" - - BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( TailscaleBinarySensorEntityDescription( key="update_available", diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index 75dca4ed840..f5850848c8c 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -21,20 +21,13 @@ from . import TailscaleEntity from .const import DOMAIN -@dataclass -class TailscaleSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class TailscaleSensorEntityDescription(SensorEntityDescription): + """Describes a Tailscale sensor entity.""" value_fn: Callable[[TailscaleDevice], datetime | str | None] -@dataclass -class TailscaleSensorEntityDescription( - SensorEntityDescription, TailscaleSensorEntityDescriptionMixin -): - """Describes a Tailscale sensor entity.""" - - SENSORS: tuple[TailscaleSensorEntityDescription, ...] = ( TailscaleSensorEntityDescription( key="expires", From aecfa6726598ebcb841eccfee6940e3d164c8872 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:48:58 +0100 Subject: [PATCH 485/982] Remove Whois entity descriptions required fields mixins (#103997) --- homeassistant/components/whois/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index beca3540e8e..0116f542a3c 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -27,20 +27,13 @@ from homeassistant.util import dt as dt_util from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN -@dataclass -class WhoisSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class WhoisSensorEntityDescription(SensorEntityDescription): + """Describes a Whois sensor entity.""" value_fn: Callable[[Domain], datetime | int | str | None] -@dataclass -class WhoisSensorEntityDescription( - SensorEntityDescription, WhoisSensorEntityDescriptionMixin -): - """Describes a Whois sensor entity.""" - - def _days_until_expiration(domain: Domain) -> int | None: """Calculate days left until domain expires.""" if domain.expiration_date is None: From e9c6da98030dc2fa3cc98f2a3013eee2c2f03fa9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:49:14 +0100 Subject: [PATCH 486/982] Remove WLED entity descriptions required fields mixins (#103996) --- homeassistant/components/wled/number.py | 11 +++-------- homeassistant/components/wled/sensor.py | 14 +++----------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 9fb18d3e113..9ab5554a6b7 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -39,18 +39,13 @@ async def async_setup_entry( update_segments() -@dataclass -class WLEDNumberDescriptionMixin: - """Mixin for WLED number.""" +@dataclass(kw_only=True) +class WLEDNumberEntityDescription(NumberEntityDescription): + """Class describing WLED number entities.""" value_fn: Callable[[Segment], float | None] -@dataclass -class WLEDNumberEntityDescription(NumberEntityDescription, WLEDNumberDescriptionMixin): - """Class describing WLED number entities.""" - - NUMBERS = [ WLEDNumberEntityDescription( key=ATTR_SPEED, diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 7d1431c093b..64cc3dc2812 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -31,20 +31,12 @@ from .coordinator import WLEDDataUpdateCoordinator from .models import WLEDEntity -@dataclass -class WLEDSensorEntityDescriptionMixin: - """Mixin for required keys.""" - - value_fn: Callable[[WLEDDevice], datetime | StateType] - - -@dataclass -class WLEDSensorEntityDescription( - SensorEntityDescription, WLEDSensorEntityDescriptionMixin -): +@dataclass(kw_only=True) +class WLEDSensorEntityDescription(SensorEntityDescription): """Describes WLED sensor entity.""" exists_fn: Callable[[WLEDDevice], bool] = lambda _: True + value_fn: Callable[[WLEDDevice], datetime | StateType] SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( From 335961943666efdc28c48407ca8fcfae52363729 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:50:11 +0100 Subject: [PATCH 487/982] Remove PVOutput entity descriptions required fields mixins (#103993) --- homeassistant/components/pvoutput/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index bcf869d3bba..d9ef71bee69 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -28,20 +28,13 @@ from .const import CONF_SYSTEM_ID, DOMAIN from .coordinator import PVOutputDataUpdateCoordinator -@dataclass -class PVOutputSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class PVOutputSensorEntityDescription(SensorEntityDescription): + """Describes a PVOutput sensor entity.""" value_fn: Callable[[Status], int | float | None] -@dataclass -class PVOutputSensorEntityDescription( - SensorEntityDescription, PVOutputSensorEntityDescriptionMixin -): - """Describes a PVOutput sensor entity.""" - - SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( PVOutputSensorEntityDescription( key="energy_consumption", From a513511936f53e041c7999bae2eadee72785c500 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 03:51:45 +0100 Subject: [PATCH 488/982] Remove Elgato entity descriptions required fields mixins (#103989) --- homeassistant/components/elgato/button.py | 13 +++---------- homeassistant/components/elgato/sensor.py | 14 +++----------- homeassistant/components/elgato/switch.py | 16 ++++------------ 3 files changed, 10 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index b05cd532c16..7a69db24012 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -23,20 +23,13 @@ from .coordinator import ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass -class ElgatoButtonEntityDescriptionMixin: - """Mixin values for Elgato entities.""" +@dataclass(kw_only=True) +class ElgatoButtonEntityDescription(ButtonEntityDescription): + """Class describing Elgato button entities.""" press_fn: Callable[[Elgato], Awaitable[Any]] -@dataclass -class ElgatoButtonEntityDescription( - ButtonEntityDescription, ElgatoButtonEntityDescriptionMixin -): - """Class describing Elgato button entities.""" - - BUTTONS = [ ElgatoButtonEntityDescription( key="identify", diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 8ed8265705c..27dedee25c9 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -26,20 +26,12 @@ from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass -class ElgatoEntityDescriptionMixin: - """Mixin values for Elgato entities.""" - - value_fn: Callable[[ElgatoData], float | int | None] - - -@dataclass -class ElgatoSensorEntityDescription( - SensorEntityDescription, ElgatoEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ElgatoSensorEntityDescription(SensorEntityDescription): """Class describing Elgato sensor entities.""" has_fn: Callable[[ElgatoData], bool] = lambda _: True + value_fn: Callable[[ElgatoData], float | int | None] SENSORS = [ diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 78af3adfa53..e9ab506c3a4 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -19,21 +19,13 @@ from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass -class ElgatoEntityDescriptionMixin: - """Mixin values for Elgato entities.""" - - is_on_fn: Callable[[ElgatoData], bool | None] - set_fn: Callable[[Elgato, bool], Awaitable[Any]] - - -@dataclass -class ElgatoSwitchEntityDescription( - SwitchEntityDescription, ElgatoEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ElgatoSwitchEntityDescription(SwitchEntityDescription): """Class describing Elgato switch entities.""" has_fn: Callable[[ElgatoData], bool] = lambda _: True + is_on_fn: Callable[[ElgatoData], bool | None] + set_fn: Callable[[Elgato, bool], Awaitable[Any]] SWITCHES = [ From f24212b66ede2e513b1dcdf3f89353f03e1472e6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 04:11:52 +0100 Subject: [PATCH 489/982] Remove TwenteMilieu entity descriptions required fields mixins (#103990) * Remove TwenteMilieu entity descriptions required fields mixins * Fix doc --- homeassistant/components/twentemilieu/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index fba10a269f7..1278f6523a5 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -21,20 +21,13 @@ from .const import DOMAIN from .entity import TwenteMilieuEntity -@dataclass -class TwenteMilieuSensorDescriptionMixin: - """Define an entity description mixin.""" +@dataclass(kw_only=True) +class TwenteMilieuSensorDescription(SensorEntityDescription): + """Describe an Twente Milieu sensor.""" waste_type: WasteType -@dataclass -class TwenteMilieuSensorDescription( - SensorEntityDescription, TwenteMilieuSensorDescriptionMixin -): - """Describe an Ambient PWS binary sensor.""" - - SENSORS: tuple[TwenteMilieuSensorDescription, ...] = ( TwenteMilieuSensorDescription( key="tree", From d1e460e97a657505fd49139c4154034a9c0b5fe9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 04:12:31 +0100 Subject: [PATCH 490/982] Remove AdGuard entity descriptions required fields mixins (#103991) --- homeassistant/components/adguard/sensor.py | 13 +++---------- homeassistant/components/adguard/switch.py | 13 +++---------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 9f1c0a5b0fe..523e1b73e16 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -22,20 +22,13 @@ SCAN_INTERVAL = timedelta(seconds=300) PARALLEL_UPDATES = 4 -@dataclass -class AdGuardHomeEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class AdGuardHomeEntityDescription(SensorEntityDescription): + """Describes AdGuard Home sensor entity.""" value_fn: Callable[[AdGuardHome], Coroutine[Any, Any, int | float]] -@dataclass -class AdGuardHomeEntityDescription( - SensorEntityDescription, AdGuardHomeEntityDescriptionMixin -): - """Describes AdGuard Home sensor entity.""" - - SENSORS: tuple[AdGuardHomeEntityDescription, ...] = ( AdGuardHomeEntityDescription( key="dns_queries", diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index 1020e8690f1..944a3c7b269 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -21,22 +21,15 @@ SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 -@dataclass -class AdGuardHomeSwitchEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class AdGuardHomeSwitchEntityDescription(SwitchEntityDescription): + """Describes AdGuard Home switch entity.""" is_on_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, bool]]] turn_on_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, None]]] turn_off_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, None]]] -@dataclass -class AdGuardHomeSwitchEntityDescription( - SwitchEntityDescription, AdGuardHomeSwitchEntityDescriptionMixin -): - """Describes AdGuard Home switch entity.""" - - SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="protection", From 182c40f16ecf10be92fe7d3677627889c6aeb515 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Wed, 15 Nov 2023 03:49:27 +0000 Subject: [PATCH 491/982] Add reauth flow to ring integration (#103758) * Add reauth flow to ring integration * Refactor re-auth flow post review * Fix threading issue on device update --- homeassistant/components/ring/__init__.py | 37 ++++--- homeassistant/components/ring/config_flow.py | 81 +++++++++++--- homeassistant/components/ring/strings.json | 10 +- tests/components/ring/test_config_flow.py | 111 +++++++++++++++++++ tests/components/ring/test_init.py | 29 ++--- 5 files changed, 221 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index a0863836a6c..7e7bff1fa53 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -13,6 +13,7 @@ import ring_doorbell from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform, __version__ from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.async_ import run_callback_threadsafe @@ -58,20 +59,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await hass.async_add_executor_job(ring.update_data) - except ring_doorbell.AuthenticationError: - _LOGGER.error("Access token is no longer valid. Please set up Ring again") - return False + except ring_doorbell.AuthenticationError as err: + _LOGGER.warning("Ring access token is no longer valid, need to re-authenticate") + raise ConfigEntryAuthFailed(err) from err hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "api": ring, "devices": ring.devices(), "device_data": GlobalDataUpdater( - hass, "device", entry.entry_id, ring, "update_devices", timedelta(minutes=1) + hass, "device", entry, ring, "update_devices", timedelta(minutes=1) ), "dings_data": GlobalDataUpdater( hass, "active dings", - entry.entry_id, + entry, ring, "update_dings", timedelta(seconds=5), @@ -79,7 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "history_data": DeviceDataUpdater( hass, "history", - entry.entry_id, + entry, ring, lambda device: device.history(limit=10), timedelta(minutes=1), @@ -87,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "health_data": DeviceDataUpdater( hass, "health", - entry.entry_id, + entry, ring, lambda device: device.update_health_data(), timedelta(minutes=1), @@ -143,7 +144,7 @@ class GlobalDataUpdater: self, hass: HomeAssistant, data_type: str, - config_entry_id: str, + config_entry: ConfigEntry, ring: ring_doorbell.Ring, update_method: str, update_interval: timedelta, @@ -151,7 +152,7 @@ class GlobalDataUpdater: """Initialize global data updater.""" self.hass = hass self.data_type = data_type - self.config_entry_id = config_entry_id + self.config_entry = config_entry self.ring = ring self.update_method = update_method self.update_interval = update_interval @@ -188,8 +189,10 @@ class GlobalDataUpdater: getattr(self.ring, self.update_method) ) except ring_doorbell.AuthenticationError: - _LOGGER.error("Ring access token is no longer valid. Set up Ring again") - await self.hass.config_entries.async_unload(self.config_entry_id) + _LOGGER.warning( + "Ring access token is no longer valid, need to re-authenticate" + ) + self.config_entry.async_start_reauth(self.hass) return except ring_doorbell.RingTimeout: _LOGGER.warning( @@ -216,7 +219,7 @@ class DeviceDataUpdater: self, hass: HomeAssistant, data_type: str, - config_entry_id: str, + config_entry: ConfigEntry, ring: ring_doorbell.Ring, update_method: Callable[[ring_doorbell.Ring], Any], update_interval: timedelta, @@ -224,7 +227,7 @@ class DeviceDataUpdater: """Initialize device data updater.""" self.data_type = data_type self.hass = hass - self.config_entry_id = config_entry_id + self.config_entry = config_entry self.ring = ring self.update_method = update_method self.update_interval = update_interval @@ -277,9 +280,11 @@ class DeviceDataUpdater: try: data = info["data"] = self.update_method(info["device"]) except ring_doorbell.AuthenticationError: - _LOGGER.error("Ring access token is no longer valid. Set up Ring again") - self.hass.add_job( - self.hass.config_entries.async_unload(self.config_entry_id) + _LOGGER.warning( + "Ring access token is no longer valid, need to re-authenticate" + ) + self.hass.loop.call_soon_threadsafe( + self.config_entry.async_start_reauth, self.hass ) return except ring_doorbell.RingTimeout: diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index b22d59a78f5..222a18fa24f 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Ring integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -6,12 +7,19 @@ import ring_doorbell import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import __version__ as ha_version +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, __version__ as ha_version +from homeassistant.data_entry_flow import FlowResult from . import DOMAIN _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect.""" @@ -39,6 +47,7 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 user_pass: dict[str, Any] = {} + reauth_entry: ConfigEntry | None = None async def async_step_user(self, user_input=None): """Handle the initial step.""" @@ -46,34 +55,34 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: token = await validate_input(self.hass, user_input) - await self.async_set_unique_id(user_input["username"]) - - return self.async_create_entry( - title=user_input["username"], - data={"username": user_input["username"], "token": token}, - ) except Require2FA: self.user_pass = user_input return await self.async_step_2fa() - except InvalidAuth: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input["username"]) + return self.async_create_entry( + title=user_input["username"], + data={"username": user_input["username"], "token": token}, + ) return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required("username"): str, vol.Required("password"): str} - ), - errors=errors, + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) async def async_step_2fa(self, user_input=None): """Handle 2fa step.""" if user_input: + if self.reauth_entry: + return await self.async_step_reauth_confirm( + {**self.user_pass, **user_input} + ) + return await self.async_step_user({**self.user_pass, **user_input}) return self.async_show_form( @@ -81,6 +90,52 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required("2fa"): str}), ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors = {} + assert self.reauth_entry is not None + + if user_input: + user_input[CONF_USERNAME] = self.reauth_entry.data[CONF_USERNAME] + try: + token = await validate_input(self.hass, user_input) + except Require2FA: + self.user_pass = user_input + return await self.async_step_2fa() + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + data = { + CONF_USERNAME: user_input[CONF_USERNAME], + "token": token, + } + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=data + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + description_placeholders={ + CONF_USERNAME: self.reauth_entry.data[CONF_USERNAME] + }, + ) + class Require2FA(exceptions.HomeAssistantError): """Error to indicate we require 2FA.""" diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index b300e335b19..688e3141beb 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -13,6 +13,13 @@ "data": { "2fa": "Two-factor code" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Ring integration needs to re-authenticate your account {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -20,7 +27,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index 0c1578e2c8d..53c7e139a51 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -10,6 +10,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_form( hass: HomeAssistant, @@ -108,3 +110,112 @@ async def test_form_2fa( "token": "new-foobar", } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_ring_auth: Mock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_ring_auth.fetch_token.side_effect = ring_doorbell.Requires2FAError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "other_fake_password", None + ) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "2fa" + mock_ring_auth.fetch_token.reset_mock(side_effect=True) + mock_ring_auth.fetch_token.return_value = "new-foobar" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={"2fa": "123456"}, + ) + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "other_fake_password", "123456" + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_added_config_entry.data == { + "username": "foo@bar.com", + "token": "new-foobar", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error_type", "errors_msg"), + [ + (ring_doorbell.AuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_reauth_error( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_ring_auth: Mock, + error_type, + errors_msg, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_ring_auth.fetch_token.side_effect = error_type + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "error_fake_password", + }, + ) + await hass.async_block_till_done() + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "error_fake_password", None + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": errors_msg} + + # Now test reauth can go on to succeed + mock_ring_auth.fetch_token.reset_mock(side_effect=True) + mock_ring_auth.fetch_token.return_value = "new-foobar" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "other_fake_password", None + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_added_config_entry.data == { + "username": "foo@bar.com", + "token": "new-foobar", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 9fa79b21fab..6ad79623a12 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -9,7 +9,7 @@ from ring_doorbell import AuthenticationError, RingError, RingTimeout import homeassistant.components.ring as ring from homeassistant.components.ring import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -46,7 +46,6 @@ async def test_auth_failed_on_setup( hass: HomeAssistant, requests_mock: requests_mock.Mocker, mock_config_entry: MockConfigEntry, - caplog, ) -> None: """Test auth failure on setup entry.""" mock_config_entry.add_to_hass(hass) @@ -54,14 +53,10 @@ async def test_auth_failed_on_setup( "ring_doorbell.Ring.update_data", side_effect=AuthenticationError, ): - result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert result is False - assert "Access token is no longer valid. Please set up Ring again" in [ - record.message for record in caplog.records if record.levelname == "ERROR" - ] - - assert DOMAIN not in hass.data + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR @@ -75,7 +70,7 @@ async def test_auth_failure_on_global_update( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - + assert not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) with patch( "ring_doorbell.Ring.update_devices", side_effect=AuthenticationError, @@ -83,11 +78,11 @@ async def test_auth_failure_on_global_update( async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) await hass.async_block_till_done() - assert "Ring access token is no longer valid. Set up Ring again" in [ - record.message for record in caplog.records if record.levelname == "ERROR" + assert "Ring access token is no longer valid, need to re-authenticate" in [ + record.message for record in caplog.records if record.levelname == "WARNING" ] - assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) async def test_auth_failure_on_device_update( @@ -100,7 +95,7 @@ async def test_auth_failure_on_device_update( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - + assert not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) with patch( "ring_doorbell.RingDoorBell.history", side_effect=AuthenticationError, @@ -108,11 +103,11 @@ async def test_auth_failure_on_device_update( async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) await hass.async_block_till_done() - assert "Ring access token is no longer valid. Set up Ring again" in [ - record.message for record in caplog.records if record.levelname == "ERROR" + assert "Ring access token is no longer valid, need to re-authenticate" in [ + record.message for record in caplog.records if record.levelname == "WARNING" ] - assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @pytest.mark.parametrize( From 8df7291abb3bf42744c4baf597e93dbf335afb74 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 06:31:10 +0100 Subject: [PATCH 492/982] Remove Withings entity descriptions required fields mixins (#104008) --- homeassistant/components/withings/sensor.py | 65 +++++---------------- 1 file changed, 15 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 707059a2930..b7ef6c6852b 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -58,20 +58,13 @@ from .coordinator import ( from .entity import WithingsEntity -@dataclass -class WithingsMeasurementSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsMeasurementSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" measurement_type: MeasurementType -@dataclass -class WithingsMeasurementSensorEntityDescription( - SensorEntityDescription, WithingsMeasurementSensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - MEASUREMENT_SENSORS: dict[ MeasurementType, WithingsMeasurementSensorEntityDescription ] = { @@ -243,20 +236,13 @@ MEASUREMENT_SENSORS: dict[ } -@dataclass -class WithingsSleepSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsSleepSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" value_fn: Callable[[SleepSummary], StateType] -@dataclass -class WithingsSleepSensorEntityDescription( - SensorEntityDescription, WithingsSleepSensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - SLEEP_SENSORS = [ WithingsSleepSensorEntityDescription( key="sleep_breathing_disturbances_intensity", @@ -410,20 +396,13 @@ SLEEP_SENSORS = [ ] -@dataclass -class WithingsActivitySensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsActivitySensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" value_fn: Callable[[Activity], StateType] -@dataclass -class WithingsActivitySensorEntityDescription( - SensorEntityDescription, WithingsActivitySensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - ACTIVITY_SENSORS = [ WithingsActivitySensorEntityDescription( key="activity_steps_today", @@ -514,20 +493,13 @@ SLEEP_GOAL = "sleep" WEIGHT_GOAL = "weight" -@dataclass -class WithingsGoalsSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsGoalsSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" value_fn: Callable[[Goals], StateType] -@dataclass -class WithingsGoalsSensorEntityDescription( - SensorEntityDescription, WithingsGoalsSensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { STEP_GOAL: WithingsGoalsSensorEntityDescription( key="step_goal", @@ -558,20 +530,13 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { } -@dataclass -class WithingsWorkoutSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsWorkoutSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" value_fn: Callable[[Workout], StateType] -@dataclass -class WithingsWorkoutSensorEntityDescription( - SensorEntityDescription, WithingsWorkoutSensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - _WORKOUT_CATEGORY = [ workout_category.name.lower() for workout_category in WorkoutCategory ] From cfac6d18f37a4e2e561e52617a400474534dc771 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 09:00:28 +0100 Subject: [PATCH 493/982] Remove HomeWizard entity descriptions required fields mixins (#103994) --- homeassistant/components/homewizard/sensor.py | 16 ++++----------- homeassistant/components/homewizard/switch.py | 20 ++++++------------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 84aa58f2d27..72b1027780a 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -35,21 +35,13 @@ from .entity import HomeWizardEntity PARALLEL_UPDATES = 1 -@dataclass -class HomeWizardEntityDescriptionMixin: - """Mixin values for HomeWizard entities.""" - - has_fn: Callable[[Data], bool] - value_fn: Callable[[Data], StateType] - - -@dataclass -class HomeWizardSensorEntityDescription( - SensorEntityDescription, HomeWizardEntityDescriptionMixin -): +@dataclass(kw_only=True) +class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" enabled_fn: Callable[[Data], bool] = lambda data: True + has_fn: Callable[[Data], bool] + value_fn: Callable[[Data], StateType] SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index ed59963aa41..3f854aad320 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -23,23 +23,15 @@ from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler -@dataclass -class HomeWizardEntityDescriptionMixin: - """Mixin values for HomeWizard entities.""" - - create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] - available_fn: Callable[[DeviceResponseEntry], bool] - is_on_fn: Callable[[DeviceResponseEntry], bool | None] - set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] - - -@dataclass -class HomeWizardSwitchEntityDescription( - SwitchEntityDescription, HomeWizardEntityDescriptionMixin -): +@dataclass(kw_only=True) +class HomeWizardSwitchEntityDescription(SwitchEntityDescription): """Class describing HomeWizard switch entities.""" + available_fn: Callable[[DeviceResponseEntry], bool] + create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] icon_off: str | None = None + is_on_fn: Callable[[DeviceResponseEntry], bool | None] + set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] SWITCHES = [ From 880483624b95537dd74fa7d626636039433a3fb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Nov 2023 09:01:36 +0100 Subject: [PATCH 494/982] Bump github/codeql-action from 2.22.5 to 2.22.6 (#104016) 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 ccd2d3c1678..9270eefe78b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.5 + uses: github/codeql-action/init@v2.22.6 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.5 + uses: github/codeql-action/analyze@v2.22.6 with: category: "/language:python" From 2d362254053ad0225adc9526fe015d7b6e9d34fd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 09:16:47 +0100 Subject: [PATCH 495/982] Remove Reolink entity descriptions required fields mixins (#104006) --- .../components/reolink/binary_sensor.py | 16 +++------- homeassistant/components/reolink/button.py | 28 ++++------------ homeassistant/components/reolink/light.py | 18 +++-------- homeassistant/components/reolink/number.py | 20 ++++-------- homeassistant/components/reolink/select.py | 16 +++------- homeassistant/components/reolink/sensor.py | 14 ++------ homeassistant/components/reolink/switch.py | 32 +++++-------------- 7 files changed, 37 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 7f2ff3e0053..bbf72056c9b 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -28,22 +28,14 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity -@dataclass -class ReolinkBinarySensorEntityDescriptionMixin: - """Mixin values for Reolink binary sensor entities.""" - - value: Callable[[Host, int], bool] - - -@dataclass -class ReolinkBinarySensorEntityDescription( - BinarySensorEntityDescription, ReolinkBinarySensorEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes binary sensor entities.""" - icon: str = "mdi:motion-sensor" icon_off: str = "mdi:motion-sensor-off" + icon: str = "mdi:motion-sensor" supported: Callable[[Host, int], bool] = lambda host, ch: True + value: Callable[[Host, int], bool] BINARY_SENSORS = ( diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index f1797527914..e0e067bd5f8 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -22,36 +22,22 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity -@dataclass -class ReolinkButtonEntityDescriptionMixin: - """Mixin values for Reolink button entities for a camera channel.""" - - method: Callable[[Host, int], Any] - - -@dataclass +@dataclass(kw_only=True) class ReolinkButtonEntityDescription( - ButtonEntityDescription, ReolinkButtonEntityDescriptionMixin + ButtonEntityDescription, ): """A class that describes button entities for a camera channel.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True enabled_default: Callable[[Host, int], bool] | None = None + method: Callable[[Host, int], Any] + supported: Callable[[Host, int], bool] = lambda api, ch: True -@dataclass -class ReolinkHostButtonEntityDescriptionMixin: - """Mixin values for Reolink button entities for the host.""" - - method: Callable[[Host], Any] - - -@dataclass -class ReolinkHostButtonEntityDescription( - ButtonEntityDescription, ReolinkHostButtonEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkHostButtonEntityDescription(ButtonEntityDescription): """A class that describes button entities for the host.""" + method: Callable[[Host], Any] supported: Callable[[Host], bool] = lambda api: True diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 938093df4a3..2f00245a0de 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -23,23 +23,15 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity -@dataclass -class ReolinkLightEntityDescriptionMixin: - """Mixin values for Reolink light entities.""" - - is_on_fn: Callable[[Host, int], bool] - turn_on_off_fn: Callable[[Host, int, bool], Any] - - -@dataclass -class ReolinkLightEntityDescription( - LightEntityDescription, ReolinkLightEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkLightEntityDescription(LightEntityDescription): """A class that describes light entities.""" - supported_fn: Callable[[Host, int], bool] = lambda api, ch: True get_brightness_fn: Callable[[Host, int], int | None] | None = None + is_on_fn: Callable[[Host, int], bool] set_brightness_fn: Callable[[Host, int, int], Any] | None = None + supported_fn: Callable[[Host, int], bool] = lambda api, ch: True + turn_on_off_fn: Callable[[Host, int, bool], Any] LIGHT_ENTITIES = ( diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 6be0cef1670..7e3f6483fb3 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -22,24 +22,16 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity -@dataclass -class ReolinkNumberEntityDescriptionMixin: - """Mixin values for Reolink number entities.""" - - value: Callable[[Host, int], float | None] - method: Callable[[Host, int, float], Any] - - -@dataclass -class ReolinkNumberEntityDescription( - NumberEntityDescription, ReolinkNumberEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkNumberEntityDescription(NumberEntityDescription): """A class that describes number entities.""" + get_max_value: Callable[[Host, int], float] | None = None + get_min_value: Callable[[Host, int], float] | None = None + method: Callable[[Host, int, float], Any] mode: NumberMode = NumberMode.AUTO supported: Callable[[Host, int], bool] = lambda api, ch: True - get_min_value: Callable[[Host, int], float] | None = None - get_max_value: Callable[[Host, int], float] | None = None + value: Callable[[Host, int], float | None] NUMBER_ENTITIES = ( diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index fd42e69268d..6cf2bf9f332 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -27,20 +27,12 @@ from .entity import ReolinkChannelCoordinatorEntity _LOGGER = logging.getLogger(__name__) -@dataclass -class ReolinkSelectEntityDescriptionMixin: - """Mixin values for Reolink select entities.""" - - method: Callable[[Host, int, str], Any] - get_options: list[str] | Callable[[Host, int], list[str]] - - -@dataclass -class ReolinkSelectEntityDescription( - SelectEntityDescription, ReolinkSelectEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkSelectEntityDescription(SelectEntityDescription): """A class that describes select entities.""" + get_options: list[str] | Callable[[Host, int], list[str]] + method: Callable[[Host, int, str], Any] supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], str] | None = None diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index b9e8ddb8e73..9a03f497944 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -24,20 +24,12 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity -@dataclass -class ReolinkSensorEntityDescriptionMixin: - """Mixin values for Reolink sensor entities for a camera channel.""" - - value: Callable[[Host, int], int] - - -@dataclass -class ReolinkSensorEntityDescription( - SensorEntityDescription, ReolinkSensorEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkSensorEntityDescription(SensorEntityDescription): """A class that describes sensor entities for a camera channel.""" supported: Callable[[Host, int], bool] = lambda api, ch: True + value: Callable[[Host, int], int] @dataclass diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index f07db00e720..0dc46d22330 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -18,38 +18,22 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity -@dataclass -class ReolinkSwitchEntityDescriptionMixin: - """Mixin values for Reolink switch entities.""" - - value: Callable[[Host, int], bool] - method: Callable[[Host, int, bool], Any] - - -@dataclass -class ReolinkSwitchEntityDescription( - SwitchEntityDescription, ReolinkSwitchEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkSwitchEntityDescription(SwitchEntityDescription): """A class that describes switch entities.""" + method: Callable[[Host, int, bool], Any] supported: Callable[[Host, int], bool] = lambda api, ch: True + value: Callable[[Host, int], bool] -@dataclass -class ReolinkNVRSwitchEntityDescriptionMixin: - """Mixin values for Reolink NVR switch entities.""" - - value: Callable[[Host], bool] - method: Callable[[Host, bool], Any] - - -@dataclass -class ReolinkNVRSwitchEntityDescription( - SwitchEntityDescription, ReolinkNVRSwitchEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkNVRSwitchEntityDescription(SwitchEntityDescription): """A class that describes NVR switch entities.""" + method: Callable[[Host, bool], Any] supported: Callable[[Host], bool] = lambda api: True + value: Callable[[Host], bool] SWITCH_ENTITIES = ( From a101bb93548cda8163d3c37bcbd3823a30b1735e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Nov 2023 09:23:25 +0100 Subject: [PATCH 496/982] Remove RDW entity descriptions required fields mixins (#103995) --- homeassistant/components/rdw/binary_sensor.py | 13 +++---------- homeassistant/components/rdw/sensor.py | 13 +++---------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 16a93485b36..96311266db4 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -23,20 +23,13 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN -@dataclass -class RDWBinarySensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class RDWBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes RDW binary sensor entity.""" is_on_fn: Callable[[Vehicle], bool | None] -@dataclass -class RDWBinarySensorEntityDescription( - BinarySensorEntityDescription, RDWBinarySensorEntityDescriptionMixin -): - """Describes RDW binary sensor entity.""" - - BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = ( RDWBinarySensorEntityDescription( key="liability_insured", diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index f330ac16b8e..d25c23c09bd 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -24,20 +24,13 @@ from homeassistant.helpers.update_coordinator import ( from .const import CONF_LICENSE_PLATE, DOMAIN -@dataclass -class RDWSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class RDWSensorEntityDescription(SensorEntityDescription): + """Describes RDW sensor entity.""" value_fn: Callable[[Vehicle], date | str | float | None] -@dataclass -class RDWSensorEntityDescription( - SensorEntityDescription, RDWSensorEntityDescriptionMixin -): - """Describes RDW sensor entity.""" - - SENSORS: tuple[RDWSensorEntityDescription, ...] = ( RDWSensorEntityDescription( key="apk_expiration", From b082ee2050c3f3fd53ae1eb55ab23c81c174a358 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 15 Nov 2023 08:30:56 +0000 Subject: [PATCH 497/982] Update systembridgeconnector to 3.10.0 (#103983) --- homeassistant/components/system_bridge/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 1bc00aee4f5..17c43fa4d24 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.9.5"], + "requirements": ["systembridgeconnector==3.10.0"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7dd72942348..edb9b2b8272 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2547,7 +2547,7 @@ switchbot-api==1.2.1 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.9.5 +systembridgeconnector==3.10.0 # homeassistant.components.tailscale tailscale==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7d68d92749..94863a9435f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1896,7 +1896,7 @@ surepy==0.8.0 switchbot-api==1.2.1 # homeassistant.components.system_bridge -systembridgeconnector==3.9.5 +systembridgeconnector==3.10.0 # homeassistant.components.tailscale tailscale==0.6.0 From 7803ca2612fa50a8b3f0c05d71f76fb8adad1e31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Nov 2023 03:27:50 -0600 Subject: [PATCH 498/982] Fix emulated_hue with None values (#104020) --- .../components/emulated_hue/hue_api.py | 25 ++++---- tests/components/emulated_hue/test_hue_api.py | 59 +++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 6dfd49c371c..4dbe5aa315e 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -676,19 +676,20 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: @lru_cache(maxsize=512) def _build_entity_state_dict(entity: State) -> dict[str, Any]: """Build a state dict for an entity.""" + is_on = entity.state != STATE_OFF data: dict[str, Any] = { - STATE_ON: entity.state != STATE_OFF, + STATE_ON: is_on, STATE_BRIGHTNESS: None, STATE_HUE: None, STATE_SATURATION: None, STATE_COLOR_TEMP: None, } - if data[STATE_ON]: + attributes = entity.attributes + if is_on: data[STATE_BRIGHTNESS] = hass_to_hue_brightness( - entity.attributes.get(ATTR_BRIGHTNESS, 0) + attributes.get(ATTR_BRIGHTNESS) or 0 ) - hue_sat = entity.attributes.get(ATTR_HS_COLOR) - if hue_sat is not None: + if (hue_sat := attributes.get(ATTR_HS_COLOR)) is not None: hue = hue_sat[0] sat = hue_sat[1] # Convert hass hs values back to hue hs values @@ -697,7 +698,7 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: else: data[STATE_HUE] = HUE_API_STATE_HUE_MIN data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN - data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0) + data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0 else: data[STATE_BRIGHTNESS] = 0 @@ -706,25 +707,23 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: data[STATE_COLOR_TEMP] = 0 if entity.domain == climate.DOMAIN: - temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) + temperature = attributes.get(ATTR_TEMPERATURE, 0) # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == humidifier.DOMAIN: - humidity = entity.attributes.get(ATTR_HUMIDITY, 0) + humidity = attributes.get(ATTR_HUMIDITY, 0) # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == media_player.DOMAIN: - level = entity.attributes.get( - ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0 - ) + level = attributes.get(ATTR_MEDIA_VOLUME_LEVEL, 1.0 if is_on else 0.0) # Convert 0.0-1.0 to 0-254 data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX) elif entity.domain == fan.DOMAIN: - percentage = entity.attributes.get(ATTR_PERCENTAGE) or 0 + percentage = attributes.get(ATTR_PERCENTAGE) or 0 # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == cover.DOMAIN: - level = entity.attributes.get(ATTR_CURRENT_POSITION, 0) + level = attributes.get(ATTR_CURRENT_POSITION, 0) data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX) _clamp_values(data) return data diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index fb5ff265497..98f99349cac 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1694,3 +1694,62 @@ async def test_specificly_exposed_entities( result_json = await async_get_lights(client) assert "1" in result_json + + +async def test_get_light_state_when_none(hass_hue: HomeAssistant, hue_client) -> None: + """Test the getting of light state when brightness is None.""" + hass_hue.states.async_set( + "light.ceiling_lights", + STATE_ON, + { + light.ATTR_BRIGHTNESS: None, + light.ATTR_RGB_COLOR: None, + light.ATTR_HS_COLOR: None, + light.ATTR_COLOR_TEMP: None, + light.ATTR_XY_COLOR: None, + light.ATTR_SUPPORTED_COLOR_MODES: [ + light.COLOR_MODE_COLOR_TEMP, + light.COLOR_MODE_HS, + light.COLOR_MODE_XY, + ], + light.ATTR_COLOR_MODE: light.COLOR_MODE_XY, + }, + ) + + light_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTPStatus.OK + ) + state = light_json["state"] + assert state[HUE_API_STATE_ON] is True + assert state[HUE_API_STATE_BRI] == 1 + assert state[HUE_API_STATE_HUE] == 0 + assert state[HUE_API_STATE_SAT] == 0 + assert state[HUE_API_STATE_CT] == 153 + + hass_hue.states.async_set( + "light.ceiling_lights", + STATE_OFF, + { + light.ATTR_BRIGHTNESS: None, + light.ATTR_RGB_COLOR: None, + light.ATTR_HS_COLOR: None, + light.ATTR_COLOR_TEMP: None, + light.ATTR_XY_COLOR: None, + light.ATTR_SUPPORTED_COLOR_MODES: [ + light.COLOR_MODE_COLOR_TEMP, + light.COLOR_MODE_HS, + light.COLOR_MODE_XY, + ], + light.ATTR_COLOR_MODE: light.COLOR_MODE_XY, + }, + ) + + light_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTPStatus.OK + ) + state = light_json["state"] + assert state[HUE_API_STATE_ON] is False + assert state[HUE_API_STATE_BRI] == 1 + assert state[HUE_API_STATE_HUE] == 0 + assert state[HUE_API_STATE_SAT] == 0 + assert state[HUE_API_STATE_CT] == 153 From 3f11bb5f62373b85aa6ba735e40c11eb4f458e65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Nov 2023 03:28:49 -0600 Subject: [PATCH 499/982] Speed up connecting to ESPHome devices (#104018) --- homeassistant/components/esphome/manager.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index d2eca7d39f9..62ef1d43a5f 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1,6 +1,7 @@ """Manager for esphome devices.""" from __future__ import annotations +import asyncio import logging from typing import TYPE_CHECKING, Any, NamedTuple @@ -454,9 +455,11 @@ class ESPHomeManager: hass, entry, entity_infos, device_info.mac_address ) await _setup_services(hass, entry_data, services) - await cli.subscribe_states(entry_data.async_update_state) - await cli.subscribe_service_calls(self.async_on_service_call) - await cli.subscribe_home_assistant_states(self.async_on_state_subscription) + await asyncio.gather( + cli.subscribe_states(entry_data.async_update_state), + cli.subscribe_service_calls(self.async_on_service_call), + cli.subscribe_home_assistant_states(self.async_on_state_subscription), + ) if device_info.voice_assistant_version: entry_data.disconnect_callbacks.append( From 51c1ea85f39ee45887ad42453bb56352945b8b58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Nov 2023 03:29:19 -0600 Subject: [PATCH 500/982] Bump zeroconf to 0.127.0 (#104017) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 970e0dd39b6..5eb77b0c41c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.126.0"] + "requirements": ["zeroconf==0.127.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 04ab96748d2..377b538eb86 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.126.0 +zeroconf==0.127.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index edb9b2b8272..070a4bf6474 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2805,7 +2805,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.126.0 +zeroconf==0.127.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94863a9435f..43c79fe8885 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2094,7 +2094,7 @@ yt-dlp==2023.10.13 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.126.0 +zeroconf==0.127.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 9326ea09a51666dcd7872f4add312967d811a5a5 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 15 Nov 2023 10:33:11 +0100 Subject: [PATCH 501/982] Update m* tests to use entity & device registry fixtures (#103980) --- tests/components/matter/test_binary_sensor.py | 2 +- tests/components/matter/test_diagnostics.py | 6 +- tests/components/matter/test_helpers.py | 2 +- tests/components/matter/test_init.py | 6 +- tests/components/matter/test_sensor.py | 2 +- .../maxcube/test_maxcube_binary_sensor.py | 12 ++- .../maxcube/test_maxcube_climate.py | 10 +- tests/components/met/test_init.py | 12 ++- tests/components/met/test_weather.py | 16 ++-- tests/components/met_eireann/test_weather.py | 16 ++-- tests/components/metoffice/test_init.py | 12 ++- tests/components/metoffice/test_weather.py | 44 +++++---- .../mikrotik/test_device_tracker.py | 11 ++- tests/components/min_max/test_init.py | 6 +- tests/components/min_max/test_sensor.py | 14 +-- .../components/minecraft_server/test_init.py | 7 +- .../mobile_app/test_binary_sensor.py | 8 +- tests/components/mobile_app/test_init.py | 14 +-- tests/components/mobile_app/test_sensor.py | 23 +++-- tests/components/mobile_app/test_webhook.py | 27 +++--- tests/components/modbus/test_binary_sensor.py | 7 +- tests/components/modbus/test_sensor.py | 5 +- .../modern_forms/test_binary_sensor.py | 10 +- tests/components/modern_forms/test_fan.py | 6 +- tests/components/modern_forms/test_init.py | 5 +- tests/components/modern_forms/test_light.py | 6 +- tests/components/modern_forms/test_sensor.py | 3 - tests/components/modern_forms/test_switch.py | 6 +- .../components/monoprice/test_media_player.py | 28 +++--- tests/components/moon/test_sensor.py | 4 +- tests/components/motioneye/test_camera.py | 16 ++-- .../components/motioneye/test_media_source.py | 20 ++-- tests/components/motioneye/test_sensor.py | 13 ++- tests/components/motioneye/test_switch.py | 13 ++- tests/components/motioneye/test_web_hooks.py | 21 ++-- tests/components/mqtt/test_device_trigger.py | 30 +++--- tests/components/mqtt/test_event.py | 9 +- tests/components/mqtt/test_init.py | 48 +++++----- tests/components/mqtt/test_mixins.py | 5 +- tests/components/mqtt/test_sensor.py | 9 +- tests/components/mqtt/test_tag.py | 23 +++-- tests/components/mqtt_room/test_sensor.py | 3 +- tests/components/mysensors/test_init.py | 4 +- tests/components/nam/test_button.py | 6 +- tests/components/nam/test_init.py | 14 +-- tests/components/nam/test_sensor.py | 95 ++++++++++--------- 46 files changed, 361 insertions(+), 298 deletions(-) diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 4dbb3b27b9c..e231012f90d 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -90,6 +90,7 @@ async def test_occupancy_sensor( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_battery_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, matter_client: MagicMock, door_lock: MatterNode, ) -> None: @@ -108,7 +109,6 @@ async def test_battery_sensor( assert state assert state.state == "on" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(entity_id) assert entry diff --git a/tests/components/matter/test_diagnostics.py b/tests/components/matter/test_diagnostics.py index 303e9879c56..c14eb93f24c 100644 --- a/tests/components/matter/test_diagnostics.py +++ b/tests/components/matter/test_diagnostics.py @@ -81,6 +81,7 @@ async def test_config_entry_diagnostics( async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, config_entry_diagnostics: dict[str, Any], device_diagnostics: dict[str, Any], @@ -102,8 +103,9 @@ async def test_device_diagnostics( ) matter_client.get_diagnostics.return_value = server_diagnostics config_entry = hass.config_entries.async_entries(DOMAIN)[0] - dev_reg = dr.async_get(hass) - device = dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)[ + 0 + ] assert device diagnostics = await get_diagnostics_for_device( diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index f7399d6aaf1..c7a0ed0d8a3 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -37,10 +37,10 @@ async def test_get_device_id( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_node_from_device_entry( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test get_node_from_device_entry.""" - device_registry = dr.async_get(hass) other_domain = "other_domain" other_config_entry = MockConfigEntry(domain=other_domain) other_config_entry.add_to_hass(hass) diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index bbe77b76af5..2286249bd5d 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -612,6 +612,8 @@ async def test_remove_entry( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_remove_config_entry_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, matter_client: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: @@ -621,11 +623,9 @@ async def test_remove_config_entry_device( await hass.async_block_till_done() config_entry = hass.config_entries.async_entries(DOMAIN)[0] - device_registry = dr.async_get(hass) device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id )[0] - entity_registry = er.async_get(hass) entity_id = "light.m5stamp_lighting_app" assert device_entry @@ -654,6 +654,7 @@ async def test_remove_config_entry_device( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_remove_config_entry_device_no_node( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, @@ -661,7 +662,6 @@ async def test_remove_config_entry_device_no_node( """Test that a device can be removed ok without an existing node.""" assert await async_setup_component(hass, "config", {}) config_entry = integration - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 2650f2b1a6f..0d8f892f992 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -187,6 +187,7 @@ async def test_temperature_sensor( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_battery_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, matter_client: MagicMock, eve_contact_sensor_node: MatterNode, ) -> None: @@ -203,7 +204,6 @@ async def test_battery_sensor( assert state assert state.state == "50" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(entity_id) assert entry diff --git a/tests/components/maxcube/test_maxcube_binary_sensor.py b/tests/components/maxcube/test_maxcube_binary_sensor.py index 65991f91b7b..0c73c548211 100644 --- a/tests/components/maxcube/test_maxcube_binary_sensor.py +++ b/tests/components/maxcube/test_maxcube_binary_sensor.py @@ -23,10 +23,12 @@ BATTERY_ENTITY_ID = f"{ENTITY_ID}_battery" async def test_window_shuttler( - hass: HomeAssistant, cube: MaxCube, windowshutter: MaxWindowShutter + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + cube: MaxCube, + windowshutter: MaxWindowShutter, ) -> None: """Test a successful setup with a shuttler device.""" - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(ENTITY_ID) entity = entity_registry.async_get(ENTITY_ID) assert entity.unique_id == "AABBCCDD03" @@ -47,10 +49,12 @@ async def test_window_shuttler( async def test_window_shuttler_battery( - hass: HomeAssistant, cube: MaxCube, windowshutter: MaxWindowShutter + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + cube: MaxCube, + windowshutter: MaxWindowShutter, ) -> None: """Test battery binary_state with a shuttler device.""" - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(BATTERY_ENTITY_ID) entity = entity_registry.async_get(BATTERY_ENTITY_ID) assert entity.unique_id == "AABBCCDD03_battery" diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index 3682c98e947..f279f049ac3 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -60,9 +60,10 @@ WALL_ENTITY_ID = "climate.testroom_testwallthermostat" VALVE_POSITION = "valve_position" -async def test_setup_thermostat(hass: HomeAssistant, cube: MaxCube) -> None: +async def test_setup_thermostat( + hass: HomeAssistant, entity_registry: er.EntityRegistry, cube: MaxCube +) -> None: """Test a successful setup of a thermostat device.""" - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(ENTITY_ID) entity = entity_registry.async_get(ENTITY_ID) assert entity.unique_id == "AABBCCDD01" @@ -96,9 +97,10 @@ async def test_setup_thermostat(hass: HomeAssistant, cube: MaxCube) -> None: assert state.attributes.get(VALVE_POSITION) == 25 -async def test_setup_wallthermostat(hass: HomeAssistant, cube: MaxCube) -> None: +async def test_setup_wallthermostat( + hass: HomeAssistant, entity_registry: er.EntityRegistry, cube: MaxCube +) -> None: """Test a successful setup of a wall thermostat device.""" - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(WALL_ENTITY_ID) entity = entity_registry.async_get(WALL_ENTITY_ID) assert entity.unique_id == "AABBCCDD02" diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py index 652763947df..0e4e46b09da 100644 --- a/tests/components/met/test_init.py +++ b/tests/components/met/test_init.py @@ -52,13 +52,15 @@ async def test_fail_default_home_entry( async def test_removing_incorrect_devices( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_weather + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + mock_weather, ) -> None: """Test we remove incorrect devices.""" entry = await init_integration(hass) - device_reg = dr.async_get(hass) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=entry.entry_id, name="Forecast_legacy", entry_type=dr.DeviceEntryType.SERVICE, @@ -71,6 +73,6 @@ async def test_removing_incorrect_devices( assert await hass.config_entries.async_reload(entry.entry_id) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert not device_reg.async_get_device(identifiers={(DOMAIN,)}) - assert device_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + assert not device_registry.async_get_device(identifiers={(DOMAIN,)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert "Removing improper device Forecast_legacy" in caplog.text diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 5a28b8eceb0..432c288383a 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -6,21 +6,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -async def test_new_config_entry(hass: HomeAssistant, mock_weather) -> None: +async def test_new_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) await hass.async_block_till_done() assert len(hass.states.async_entity_ids("weather")) == 1 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 -async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: +async def test_legacy_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "home-hourly", @@ -30,7 +32,7 @@ async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 2 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 2 async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: diff --git a/tests/components/met_eireann/test_weather.py b/tests/components/met_eireann/test_weather.py index a3ca1fd55f7..dce1bff1c7c 100644 --- a/tests/components/met_eireann/test_weather.py +++ b/tests/components/met_eireann/test_weather.py @@ -32,20 +32,22 @@ async def setup_config_entry(hass: HomeAssistant) -> ConfigEntry: return mock_data -async def test_new_config_entry(hass: HomeAssistant, mock_weather) -> None: +async def test_new_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) await setup_config_entry(hass) assert len(hass.states.async_entity_ids("weather")) == 1 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 -async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: +async def test_legacy_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "10-20-hourly", @@ -54,7 +56,7 @@ async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 2 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 2 async def test_weather(hass: HomeAssistant, mock_weather) -> None: diff --git a/tests/components/metoffice/test_init.py b/tests/components/metoffice/test_init.py index a9e286907d5..10ed0a83f0c 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -89,6 +89,7 @@ from tests.common import MockConfigEntry ) async def test_migrate_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, old_unique_id: str, new_unique_id: str, migration_needed: bool, @@ -102,9 +103,7 @@ async def test_migrate_unique_id( ) entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - - entity: er.RegistryEntry = ent_reg.async_get_or_create( + entity: er.RegistryEntry = entity_registry.async_get_or_create( suggested_object_id="my_sensor", disabled_by=None, domain=SENSOR_DOMAIN, @@ -118,9 +117,12 @@ async def test_migrate_unique_id( await hass.async_block_till_done() if migration_needed: - assert ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) is None + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + is None + ) assert ( - ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, new_unique_id) + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, new_unique_id) == "sensor.my_sensor" ) diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 8930d318ec7..b87a63a5a46 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -100,13 +100,15 @@ async def test_site_cannot_connect( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_site_cannot_update( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + wavertree_data, ) -> None: """Test we handle cannot connect error.""" - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", @@ -143,13 +145,15 @@ async def test_site_cannot_update( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_one_weather_site_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + wavertree_data, ) -> None: """Test the Met Office weather platform.""" - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", @@ -219,19 +223,21 @@ async def test_one_weather_site_running( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_two_weather_sites_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + wavertree_data, ) -> None: """Test we handle two different weather sites both running.""" - registry = er.async_get(hass) # Pre-create the hourly entities - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", suggested_object_id="met_office_wavertree_3_hourly", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "52.75556_0.44231", @@ -369,9 +375,10 @@ async def test_two_weather_sites_running( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) -async def test_new_config_entry(hass: HomeAssistant, no_sensor, wavertree_data) -> None: +async def test_new_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, no_sensor, wavertree_data +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) entry = MockConfigEntry( domain=DOMAIN, @@ -383,17 +390,16 @@ async def test_new_config_entry(hass: HomeAssistant, no_sensor, wavertree_data) assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_legacy_config_entry( - hass: HomeAssistant, no_sensor, wavertree_data + hass: HomeAssistant, entity_registry: er.EntityRegistry, no_sensor, wavertree_data ) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", @@ -411,7 +417,7 @@ async def test_legacy_config_entry( assert len(hass.states.async_entity_ids("weather")) == 2 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 2 @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) @@ -510,6 +516,7 @@ async def test_forecast_service( async def test_forecast_subscription( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, no_sensor, @@ -519,9 +526,8 @@ async def test_forecast_subscription( """Test multiple forecast.""" client = await hass_ws_client(hass) - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 84fcfabffee..55cebaec525 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -208,29 +208,30 @@ async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) assert device_4.attributes["host_name"] == "Device_4" -async def test_restoring_devices(hass: HomeAssistant) -> None: +async def test_restoring_devices( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" config_entry = MockConfigEntry( domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS ) config_entry.add_to_hass(hass) - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( device_tracker.DOMAIN, mikrotik.DOMAIN, "00:00:00:00:00:01", suggested_object_id="device_1", config_entry=config_entry, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( device_tracker.DOMAIN, mikrotik.DOMAIN, "00:00:00:00:00:02", suggested_object_id="device_2", config_entry=config_entry, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( device_tracker.DOMAIN, mikrotik.DOMAIN, "00:00:00:00:00:03", diff --git a/tests/components/min_max/test_init.py b/tests/components/min_max/test_init.py index 8d8eac5c700..cd07f7060f6 100644 --- a/tests/components/min_max/test_init.py +++ b/tests/components/min_max/test_init.py @@ -11,6 +11,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("platform", ("sensor",)) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, platform: str, ) -> None: """Test setting up and removing a config entry.""" @@ -19,7 +20,6 @@ async def test_setup_and_remove_config_entry( input_sensors = ["sensor.input_one", "sensor.input_two"] - registry = er.async_get(hass) min_max_entity_id = f"{platform}.my_min_max" # Setup the config entry @@ -39,7 +39,7 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(min_max_entity_id) is not None + assert entity_registry.async_get(min_max_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(min_max_entity_id) @@ -51,4 +51,4 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(min_max_entity_id) is None - assert registry.async_get(min_max_entity_id) is None + assert entity_registry.async_get(min_max_entity_id) is None diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index a742260daff..acd42f9355e 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -60,7 +60,9 @@ async def test_default_name_sensor(hass: HomeAssistant) -> None: assert entity_ids[2] == state.attributes.get("min_entity_id") -async def test_min_sensor(hass: HomeAssistant) -> None: +async def test_min_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the min sensor.""" config = { "sensor": { @@ -87,8 +89,7 @@ async def test_min_sensor(hass: HomeAssistant) -> None: assert entity_ids[2] == state.attributes.get("min_entity_id") assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entity_reg = er.async_get(hass) - entity = entity_reg.async_get("sensor.test_min") + entity = entity_registry.async_get("sensor.test_min") assert entity.unique_id == "very_unique_id" @@ -470,7 +471,9 @@ async def test_sensor_incorrect_state( assert "Unable to store state. Only numerical states are supported" in caplog.text -async def test_sum_sensor(hass: HomeAssistant) -> None: +async def test_sum_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the sum sensor.""" config = { "sensor": { @@ -496,8 +499,7 @@ async def test_sum_sensor(hass: HomeAssistant) -> None: assert str(float(SUM_VALUE)) == state.state assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entity_reg = er.async_get(hass) - entity = entity_reg.async_get("sensor.test_sum") + entity = entity_registry.async_get("sensor.test_sum") assert entity.unique_id == "very_unique_id_sum_sensor" diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 09e411f0b62..018fdac542e 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -178,7 +178,10 @@ async def test_setup_entry_not_ready( async def test_entry_migration( - hass: HomeAssistant, v1_mock_config_entry: MockConfigEntry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + v1_mock_config_entry: MockConfigEntry, ) -> None: """Test entry migration from version 1 to 3, where host and port is required for the connection to the server.""" v1_mock_config_entry.add_to_hass(hass) @@ -218,12 +221,10 @@ async def test_entry_migration( assert migrated_config_entry.state == ConfigEntryState.LOADED # Test migrated device entry. - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(device_entry_id) assert device_entry.identifiers == {(DOMAIN, migrated_config_entry.entry_id)} # Test migrated sensor entity entries. - entity_registry = er.async_get(hass) for mapping in sensor_entity_id_key_mapping_list: entity_entry = entity_registry.async_get(mapping["entity_id"]) assert ( diff --git a/tests/components/mobile_app/test_binary_sensor.py b/tests/components/mobile_app/test_binary_sensor.py index b8a6cbb6db6..fe3510865fc 100644 --- a/tests/components/mobile_app/test_binary_sensor.py +++ b/tests/components/mobile_app/test_binary_sensor.py @@ -9,7 +9,10 @@ from homeassistant.helpers import device_registry as dr async def test_sensor( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_registrations, + webhook_client, ) -> None: """Test that sensors can be registered and updated.""" webhook_id = create_registrations[1]["webhook_id"] @@ -77,8 +80,7 @@ async def test_sensor( assert updated_entity.state == "off" assert "foo" not in updated_entity.attributes - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == len(create_registrations) + assert len(device_registry.devices) == len(create_registrations) # Reload to verify state is restored config_entry = hass.config_entries.async_entries("mobile_app")[1] diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index 8b034fb4ba9..59f2a130737 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -28,14 +28,16 @@ async def test_unload_unloads( assert len(calls) == 1 -async def test_remove_entry(hass: HomeAssistant, create_registrations) -> None: +async def test_remove_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + create_registrations, +) -> None: """Test we clean up when we remove entry.""" for config_entry in hass.config_entries.async_entries("mobile_app"): await hass.config_entries.async_remove(config_entry.entry_id) assert config_entry.data["webhook_id"] in hass.data[DOMAIN][DATA_DELETED_IDS] - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 0 - - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 0 + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index 8c8bf45fde2..f7c4a5690db 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -25,6 +25,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM ) async def test_sensor( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, create_registrations, webhook_client, unit_system, @@ -77,9 +79,7 @@ async def test_sensor( assert entity.state == state1 assert ( - er.async_get(hass) - .async_get("sensor.test_1_battery_temperature") - .entity_category + entity_registry.async_get("sensor.test_1_battery_temperature").entity_category == "diagnostic" ) @@ -109,8 +109,7 @@ async def test_sensor( assert updated_entity.state == state2 assert "foo" not in updated_entity.attributes - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == len(create_registrations) + assert len(device_registry.devices) == len(create_registrations) # Reload to verify state is restored config_entry = hass.config_entries.async_entries("mobile_app")[1] @@ -503,7 +502,10 @@ async def test_sensor_datetime( async def test_default_disabling_entity( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations, + webhook_client, ) -> None: """Test that sensors can be disabled by default upon registration.""" webhook_id = create_registrations[1]["webhook_id"] @@ -532,13 +534,16 @@ async def test_default_disabling_entity( assert entity is None assert ( - er.async_get(hass).async_get("sensor.test_1_battery_state").disabled_by + entity_registry.async_get("sensor.test_1_battery_state").disabled_by == er.RegistryEntryDisabler.INTEGRATION ) async def test_updating_disabled_sensor( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations, + webhook_client, ) -> None: """Test that sensors return error if disabled in instance.""" webhook_id = create_registrations[1]["webhook_id"] @@ -580,7 +585,7 @@ async def test_updating_disabled_sensor( assert json["battery_state"]["success"] is True assert "is_disabled" not in json["battery_state"] - er.async_get(hass).async_update_entity( + entity_registry.async_update_entity( "sensor.test_1_battery_state", disabled_by=er.RegistryEntryDisabler.USER ) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 9f6aec404e2..6fe272fbc40 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -854,12 +854,13 @@ async def test_webhook_camera_stream_stream_available_but_errors( async def test_webhook_handle_scan_tag( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_registrations, + webhook_client, ) -> None: """Test that we can scan tags.""" - device = dr.async_get(hass).async_get_device( - identifiers={(DOMAIN, "mock-device-id")} - ) + device = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")}) assert device is not None events = async_capture_events(hass, EVENT_TAG_SCANNED) @@ -920,7 +921,10 @@ async def test_register_sensor_limits_state_class( async def test_reregister_sensor( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations, + webhook_client, ) -> None: """Test that we can add more info in re-registration.""" webhook_id = create_registrations[1]["webhook_id"] @@ -941,8 +945,7 @@ async def test_reregister_sensor( assert reg_resp.status == HTTPStatus.CREATED - ent_reg = er.async_get(hass) - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.original_name == "Test 1 Battery State" assert entry.device_class is None assert entry.unit_of_measurement is None @@ -970,7 +973,7 @@ async def test_reregister_sensor( ) assert reg_resp.status == HTTPStatus.CREATED - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.original_name == "Test 1 New Name" assert entry.device_class == "battery" assert entry.unit_of_measurement == "%" @@ -992,7 +995,7 @@ async def test_reregister_sensor( ) assert reg_resp.status == HTTPStatus.CREATED - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.disabled_by is None reg_resp = await webhook_client.post( @@ -1014,7 +1017,7 @@ async def test_reregister_sensor( ) assert reg_resp.status == HTTPStatus.CREATED - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.original_name == "Test 1 New Name 2" assert entry.device_class is None assert entry.unit_of_measurement is None @@ -1067,6 +1070,7 @@ async def test_webhook_handle_conversation_process( async def test_sending_sensor_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, create_registrations, webhook_client, caplog: pytest.LogCaptureFixture, @@ -1105,8 +1109,7 @@ async def test_sending_sensor_state( assert reg_resp.status == HTTPStatus.CREATED - ent_reg = er.async_get(hass) - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.original_name == "Test 1 Battery State" assert entry.device_class is None assert entry.unit_of_measurement is None diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 2069aa23b8f..a892dd205fb 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -445,11 +445,14 @@ async def test_config_virtual_binary_sensor(hass: HomeAssistant, mock_modbus) -> ], ) async def test_virtual_binary_sensor( - hass: HomeAssistant, expected, slaves, mock_do_cycle + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + expected, + slaves, + mock_do_cycle, ) -> None: """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected - entity_registry = er.async_get(hass) for i, slave in enumerate(slaves): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i+1}".replace(" ", "_") diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 1c627faa09c..51202ded191 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -869,9 +869,10 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: ), ], ) -async def test_virtual_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: +async def test_virtual_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_do_cycle, expected +) -> None: """Run test for sensor.""" - entity_registry = er.async_get(hass) for i in range(0, len(expected)): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") unique_id = f"{SLAVE_UNIQUE_ID}" diff --git a/tests/components/modern_forms/test_binary_sensor.py b/tests/components/modern_forms/test_binary_sensor.py index 6b64beb4f1a..3ea0fca99d5 100644 --- a/tests/components/modern_forms/test_binary_sensor.py +++ b/tests/components/modern_forms/test_binary_sensor.py @@ -11,20 +11,20 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the creation and values of the Modern Forms sensors.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( BINARY_SENSOR_DOMAIN, DOMAIN, "AA:BB:CC:DD:EE:FF_light_sleep_timer_active", suggested_object_id="modernformsfan_light_sleep_timer_active", disabled_by=None, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( BINARY_SENSOR_DOMAIN, DOMAIN, "AA:BB:CC:DD:EE:FF_fan_sleep_timer_active", diff --git a/tests/components/modern_forms/test_fan.py b/tests/components/modern_forms/test_fan.py index 12083bb5ab6..9dc5ca9960f 100644 --- a/tests/components/modern_forms/test_fan.py +++ b/tests/components/modern_forms/test_fan.py @@ -35,13 +35,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_fan_state( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the creation and values of the Modern Forms fans.""" await init_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - state = hass.states.get("fan.modernformsfan_fan") assert state assert state.attributes.get(ATTR_PERCENTAGE) == 50 diff --git a/tests/components/modern_forms/test_init.py b/tests/components/modern_forms/test_init.py index b989f0f9ef3..9befb36d00d 100644 --- a/tests/components/modern_forms/test_init.py +++ b/tests/components/modern_forms/test_init.py @@ -38,13 +38,14 @@ async def test_unload_config_entry( async def test_fan_only_device( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test we set unique ID if not set yet.""" await init_integration( hass, aioclient_mock, mock_type=modern_forms_no_light_call_mock ) - entity_registry = er.async_get(hass) fan_entry = entity_registry.async_get("fan.modernformsfan_fan") assert fan_entry diff --git a/tests/components/modern_forms/test_light.py b/tests/components/modern_forms/test_light.py index 7e5b5e824f2..080290944b2 100644 --- a/tests/components/modern_forms/test_light.py +++ b/tests/components/modern_forms/test_light.py @@ -28,13 +28,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_light_state( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the creation and values of the Modern Forms lights.""" await init_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - state = hass.states.get("light.modernformsfan_light") assert state assert state.attributes.get(ATTR_BRIGHTNESS) == 128 diff --git a/tests/components/modern_forms/test_sensor.py b/tests/components/modern_forms/test_sensor.py index 7e3914cd7d9..279942f39a9 100644 --- a/tests/components/modern_forms/test_sensor.py +++ b/tests/components/modern_forms/test_sensor.py @@ -4,7 +4,6 @@ from datetime import datetime from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from . import init_integration, modern_forms_timers_set_mock @@ -18,7 +17,6 @@ async def test_sensors( # await init_integration(hass, aioclient_mock) await init_integration(hass, aioclient_mock) - er.async_get(hass) # Light timer remaining time state = hass.states.get("sensor.modernformsfan_light_sleep_time") @@ -42,7 +40,6 @@ async def test_active_sensors( # await init_integration(hass, aioclient_mock) await init_integration(hass, aioclient_mock, mock_type=modern_forms_timers_set_mock) - er.async_get(hass) # Light timer remaining time state = hass.states.get("sensor.modernformsfan_light_sleep_time") diff --git a/tests/components/modern_forms/test_switch.py b/tests/components/modern_forms/test_switch.py index eae51d034f6..b0ddc31150b 100644 --- a/tests/components/modern_forms/test_switch.py +++ b/tests/components/modern_forms/test_switch.py @@ -22,13 +22,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_switch_state( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the creation and values of the Modern Forms switches.""" await init_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - state = hass.states.get("switch.modernformsfan_away_mode") assert state assert state.attributes.get(ATTR_ICON) == "mdi:airplane-takeoff" diff --git a/tests/components/monoprice/test_media_player.py b/tests/components/monoprice/test_media_player.py index fb1c2ece186..c2f9ef01111 100644 --- a/tests/components/monoprice/test_media_player.py +++ b/tests/components/monoprice/test_media_player.py @@ -489,45 +489,45 @@ async def test_volume_up_down(hass: HomeAssistant) -> None: assert monoprice.zones[11].volume == 37 -async def test_first_run_with_available_zones(hass: HomeAssistant) -> None: +async def test_first_run_with_available_zones( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test first run with all zones available.""" monoprice = MockMonoprice() await _setup_monoprice(hass, monoprice) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert not entry.disabled -async def test_first_run_with_failing_zones(hass: HomeAssistant) -> None: +async def test_first_run_with_failing_zones( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test first run with failed zones.""" monoprice = MockMonoprice() with patch.object(MockMonoprice, "zone_status", side_effect=SerialException): await _setup_monoprice(hass, monoprice) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_1_ID) + entry = entity_registry.async_get(ZONE_1_ID) assert not entry.disabled - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION -async def test_not_first_run_with_failing_zone(hass: HomeAssistant) -> None: +async def test_not_first_run_with_failing_zone( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test first run with failed zones.""" monoprice = MockMonoprice() with patch.object(MockMonoprice, "zone_status", side_effect=SerialException): await _setup_monoprice_not_first_run(hass, monoprice) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_1_ID) + entry = entity_registry.async_get(ZONE_1_ID) assert not entry.disabled - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert not entry.disabled diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py index 922febed3bf..38af8dcb912 100644 --- a/tests/components/moon/test_sensor.py +++ b/tests/components/moon/test_sensor.py @@ -39,6 +39,8 @@ from tests.common import MockConfigEntry ) async def test_moon_day( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, moon_value: float, native_value: str, @@ -70,13 +72,11 @@ async def test_moon_day( STATE_WANING_CRESCENT, ] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.moon_phase") assert entry assert entry.unique_id == mock_config_entry.entry_id assert entry.translation_key == "phase" - device_registry = dr.async_get(hass) assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 5f5c5f7854e..5af8d4139eb 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -135,10 +135,12 @@ async def test_setup_camera_new_data_same(hass: HomeAssistant) -> None: assert hass.states.get(TEST_CAMERA_ENTITY_ID) -async def test_setup_camera_new_data_camera_removed(hass: HomeAssistant) -> None: +async def test_setup_camera_new_data_camera_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test a data refresh with a removed camera.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) client = create_mock_motioneye_client() config_entry = await setup_mock_motioneye_config_entry(hass, client=client) @@ -315,12 +317,15 @@ async def test_state_attributes(hass: HomeAssistant) -> None: assert not entity_state.attributes.get("motion_detection") -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" entry = await setup_mock_motioneye_config_entry(hass) device_identifier = get_motioneye_device_identifier(entry.entry_id, TEST_CAMERA_ID) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={device_identifier}) assert device @@ -330,7 +335,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert device.model == MOTIONEYE_MANUFACTURER assert device.name == TEST_CAMERA_NAME - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index cb42e51f474..6b90870c4da 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -78,13 +78,14 @@ async def setup_media_source(hass) -> None: assert await async_setup_component(hass, "media_source", {}) -async def test_async_browse_media_success(hass: HomeAssistant) -> None: +async def test_async_browse_media_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test successful browse media.""" client = create_mock_motioneye_client() config = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, @@ -295,13 +296,14 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None: } -async def test_async_browse_media_images_success(hass: HomeAssistant) -> None: +async def test_async_browse_media_images_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test successful browse media of images.""" client = create_mock_motioneye_client() config = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, @@ -346,14 +348,15 @@ async def test_async_browse_media_images_success(hass: HomeAssistant) -> None: } -async def test_async_resolve_media_success(hass: HomeAssistant) -> None: +async def test_async_resolve_media_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test successful resolve media.""" client = create_mock_motioneye_client() config = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, @@ -380,14 +383,15 @@ async def test_async_resolve_media_success(hass: HomeAssistant) -> None: assert client.get_image_url.call_args == call(TEST_CAMERA_ID, "/foo.jpg") -async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: +async def test_async_resolve_media_failure( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test failed resolve media calls.""" client = create_mock_motioneye_client() config = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, diff --git a/tests/components/motioneye/test_sensor.py b/tests/components/motioneye/test_sensor.py index 659738ef2c5..0892c0dead0 100644 --- a/tests/components/motioneye/test_sensor.py +++ b/tests/components/motioneye/test_sensor.py @@ -73,7 +73,11 @@ async def test_sensor_actions( assert entity_state.attributes.get(KEY_ACTIONS) is None -async def test_sensor_device_info(hass: HomeAssistant) -> None: +async def test_sensor_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" # Enable the action sensor (it is disabled by default). @@ -91,11 +95,9 @@ async def test_sensor_device_info(hass: HomeAssistant) -> None: config_entry.entry_id, TEST_CAMERA_ID ) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={device_identifer}) assert device - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) @@ -104,12 +106,13 @@ async def test_sensor_device_info(hass: HomeAssistant) -> None: async def test_sensor_actions_can_be_enabled( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Verify the action sensor can be enabled.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(TEST_SENSOR_ACTION_ENTITY_ID) assert entry diff --git a/tests/components/motioneye/test_switch.py b/tests/components/motioneye/test_switch.py index cc193f5fb60..a6fbcc49052 100644 --- a/tests/components/motioneye/test_switch.py +++ b/tests/components/motioneye/test_switch.py @@ -152,7 +152,9 @@ async def test_switch_has_correct_entities(hass: HomeAssistant) -> None: async def test_disabled_switches_can_be_enabled( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Verify disabled switches can be enabled.""" client = create_mock_motioneye_client() @@ -165,7 +167,6 @@ async def test_disabled_switches_can_be_enabled( for switch_key in disabled_switch_keys: entity_id = f"{TEST_SWITCH_ENTITY_ID_BASE}_{switch_key}" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled @@ -191,19 +192,21 @@ async def test_disabled_switches_can_be_enabled( assert entity_state -async def test_switch_device_info(hass: HomeAssistant) -> None: +async def test_switch_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" config_entry = await setup_mock_motioneye_config_entry(hass) device_identifer = get_motioneye_device_identifier( config_entry.entry_id, TEST_CAMERA_ID ) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={device_identifer}) assert device - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index 617f472ab4e..7c66645bb44 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -63,12 +63,13 @@ WEB_HOOK_FILE_STORED_QUERY_STRING = ( ) -async def test_setup_camera_without_webhook(hass: HomeAssistant) -> None: +async def test_setup_camera_without_webhook( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test a camera with no webhook.""" client = create_mock_motioneye_client() config_entry = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} ) @@ -95,6 +96,7 @@ async def test_setup_camera_without_webhook(hass: HomeAssistant) -> None: async def test_setup_camera_with_wrong_webhook( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Test camera with wrong web hook.""" wrong_url = "http://wrong-url" @@ -123,7 +125,6 @@ async def test_setup_camera_with_wrong_webhook( ) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} ) @@ -151,6 +152,7 @@ async def test_setup_camera_with_wrong_webhook( async def test_setup_camera_with_old_webhook( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Verify that webhooks are overwritten if they are from this integration. @@ -176,7 +178,6 @@ async def test_setup_camera_with_old_webhook( ) assert client.async_set_camera.called - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} ) @@ -204,6 +205,7 @@ async def test_setup_camera_with_old_webhook( async def test_setup_camera_with_correct_webhook( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Verify that webhooks are not overwritten if they are already correct.""" @@ -212,7 +214,6 @@ async def test_setup_camera_with_correct_webhook( hass, data={CONF_URL: TEST_URL, CONF_WEBHOOK_ID: "webhook_secret_id"} ) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, @@ -278,12 +279,13 @@ async def test_setup_camera_with_no_home_assistant_urls( async def test_good_query( - hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test good callbacks.""" await async_setup_component(hass, "http", {"http": {}}) - device_registry = dr.async_get(hass) client = create_mock_motioneye_client() config_entry = await setup_mock_motioneye_config_entry(hass, client=client) @@ -377,12 +379,13 @@ async def test_bad_query_cannot_decode( async def test_event_media_data( - hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test an event with a file path generates media data.""" await async_setup_component(hass, "http", {"http": {}}) - device_registry = dr.async_get(hass) client = create_mock_motioneye_client() config_entry = await setup_mock_motioneye_config_entry(hass, client=client) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 485c2774f7b..90360bf7e3f 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -973,11 +973,12 @@ async def test_attach_remove_late2( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT device registry integration.""" await mqtt_mock_entry() - registry = dr.async_get(hass) data = json.dumps( { @@ -998,7 +999,7 @@ async def test_entity_device_info_with_connection( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device( + device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None @@ -1011,11 +1012,12 @@ async def test_entity_device_info_with_connection( async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT device registry integration.""" await mqtt_mock_entry() - registry = dr.async_get(hass) data = json.dumps( { @@ -1036,7 +1038,7 @@ async def test_entity_device_info_with_identifier( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -1047,11 +1049,12 @@ async def test_entity_device_info_with_identifier( async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test device registry update.""" await mqtt_mock_entry() - registry = dr.async_get(hass) config = { "automation_type": "trigger", @@ -1072,7 +1075,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Beer" @@ -1081,7 +1084,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Milk" @@ -1390,14 +1393,15 @@ async def test_cleanup_device_with_entity2( async def test_trigger_debug_info( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug_info. This is a test helper for MQTT debug_info. """ await mqtt_mock_entry() - registry = dr.async_get(hass) config1 = { "platform": "mqtt", @@ -1429,7 +1433,7 @@ async def test_trigger_debug_info( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data) await hass.async_block_till_done() - device = registry.async_get_device( + device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 4c0e63fec1f..e178eb40c0e 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -500,14 +500,15 @@ async def test_entity_id_update_discovery_update( async def test_entity_device_info_with_hub( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT event device registry integration.""" await mqtt_mock_entry() other_config_entry = MockConfigEntry() other_config_entry.add_to_hass(hass) - registry = dr.async_get(hass) - hub = registry.async_get_or_create( + hub = device_registry.async_get_or_create( config_entry_id=other_config_entry.entry_id, connections=set(), identifiers={("mqtt", "hub-id")}, @@ -527,7 +528,7 @@ async def test_entity_device_info_with_hub( async_fire_mqtt_message(hass, "homeassistant/event/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.via_device_id == hub.id diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 5bb86662322..d31570548f0 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3050,7 +3050,9 @@ async def test_mqtt_ws_get_device_debug_info_binary( async def test_debug_info_multiple_devices( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test we get correct debug_info when multiple devices are present.""" await mqtt_mock_entry() @@ -3097,8 +3099,6 @@ async def test_debug_info_multiple_devices( }, ] - registry = dr.async_get(hass) - for dev in devices: data = json.dumps(dev["config"]) domain = dev["domain"] @@ -3109,7 +3109,7 @@ async def test_debug_info_multiple_devices( for dev in devices: domain = dev["domain"] id = dev["config"]["device"]["identifiers"][0] - device = registry.async_get_device(identifiers={("mqtt", id)}) + device = device_registry.async_get_device(identifiers={("mqtt", id)}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3132,7 +3132,9 @@ async def test_debug_info_multiple_devices( async def test_debug_info_multiple_entities_triggers( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test we get correct debug_info for a device with multiple entities and triggers.""" await mqtt_mock_entry() @@ -3179,8 +3181,6 @@ async def test_debug_info_multiple_entities_triggers( }, ] - registry = dr.async_get(hass) - for c in config: data = json.dumps(c["config"]) domain = c["domain"] @@ -3190,7 +3190,7 @@ async def test_debug_info_multiple_entities_triggers( await hass.async_block_till_done() device_id = config[0]["config"]["device"]["identifiers"][0] - device = registry.async_get_device(identifiers={("mqtt", device_id)}) + device = device_registry.async_get_device(identifiers={("mqtt", device_id)}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"]) == 2 @@ -3253,7 +3253,9 @@ async def test_debug_info_non_mqtt( async def test_debug_info_wildcard( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3264,13 +3266,11 @@ async def test_debug_info_wildcard( "unique_id": "veryunique", } - registry = dr.async_get(hass) - data = json.dumps(config) async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3301,7 +3301,9 @@ async def test_debug_info_wildcard( async def test_debug_info_filter_same( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug info removes messages with same timestamp.""" await mqtt_mock_entry() @@ -3312,13 +3314,11 @@ async def test_debug_info_filter_same( "unique_id": "veryunique", } - registry = dr.async_get(hass) - data = json.dumps(config) async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3361,7 +3361,9 @@ async def test_debug_info_filter_same( async def test_debug_info_same_topic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3373,13 +3375,11 @@ async def test_debug_info_same_topic( "unique_id": "veryunique", } - registry = dr.async_get(hass) - data = json.dumps(config) async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3415,7 +3415,9 @@ async def test_debug_info_same_topic( async def test_debug_info_qos_retain( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3426,13 +3428,11 @@ async def test_debug_info_qos_retain( "unique_id": "veryunique", } - registry = dr.async_get(hass) - data = json.dumps(config) async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 1ca9bf07d72..7a625a2f5f6 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -312,6 +312,7 @@ async def test_availability_with_shared_state_topic( @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) async def test_default_entity_and_device_name( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data, caplog: pytest.LogCaptureFixture, @@ -336,9 +337,7 @@ async def test_default_entity_and_device_name( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - registry = dr.async_get(hass) - - device = registry.async_get_device({("mqtt", "helloworld")}) + device = device_registry.async_get_device({("mqtt", "helloworld")}) assert device is not None assert device.name == device_name diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 0f1be02875c..e33d626c5d8 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -1134,14 +1134,15 @@ async def test_entity_id_update_discovery_update( async def test_entity_device_info_with_hub( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT sensor device registry integration.""" await mqtt_mock_entry() other_config_entry = MockConfigEntry() other_config_entry.add_to_hass(hass) - registry = dr.async_get(hass) - hub = registry.async_get_or_create( + hub = device_registry.async_get_or_create( config_entry_id=other_config_entry.entry_id, connections=set(), identifiers={("mqtt", "hub-id")}, @@ -1160,7 +1161,7 @@ async def test_entity_device_info_with_hub( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.via_device_id == hub.id diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 55eac636edb..0476c880b1a 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -444,11 +444,12 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT device registry integration.""" await mqtt_mock_entry() - registry = dr.async_get(hass) data = json.dumps( { @@ -466,7 +467,7 @@ async def test_entity_device_info_with_connection( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device( + device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None @@ -479,11 +480,12 @@ async def test_entity_device_info_with_connection( async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT device registry integration.""" await mqtt_mock_entry() - registry = dr.async_get(hass) data = json.dumps( { @@ -501,7 +503,7 @@ async def test_entity_device_info_with_identifier( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -512,11 +514,12 @@ async def test_entity_device_info_with_identifier( async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test device registry update.""" await mqtt_mock_entry() - registry = dr.async_get(hass) config = { "topic": "test-topic", @@ -534,7 +537,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Beer" @@ -543,7 +546,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Milk" diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index 72540f49ca7..822e028f4f6 100644 --- a/tests/components/mqtt_room/test_sensor.py +++ b/tests/components/mqtt_room/test_sensor.py @@ -118,7 +118,7 @@ async def test_room_update(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> async def test_unique_id_is_set( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, entity_registry: er.EntityRegistry, mqtt_mock: MqttMockHAClient ) -> None: """Test the updating between rooms.""" unique_name = "my_unique_name_0123456789" @@ -141,6 +141,5 @@ async def test_unique_id_is_set( state = hass.states.get(SENSOR_STATE) assert state.state is not None - entity_registry = er.async_get(hass) entry = entity_registry.async_get(SENSOR_STATE) assert entry.unique_id == unique_name diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 9d1867b3158..fd61e27a663 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -15,6 +15,8 @@ from tests.typing import WebSocketGenerator async def test_remove_config_entry_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, gps_sensor: Sensor, integration: MockConfigEntry, gateway: BaseSyncGateway, @@ -27,11 +29,9 @@ async def test_remove_config_entry_device( assert await async_setup_component(hass, "config", {}) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"{config_entry.entry_id}-{node_id}")} ) - entity_registry = er.async_get(hass) state = hass.states.get(entity_id) assert gateway.sensors diff --git a/tests/components/nam/test_button.py b/tests/components/nam/test_button.py index 4a1083874d0..ab4e46975f9 100644 --- a/tests/components/nam/test_button.py +++ b/tests/components/nam/test_button.py @@ -10,10 +10,8 @@ from homeassistant.util import dt as dt_util from . import init_integration -async def test_button(hass: HomeAssistant) -> None: +async def test_button(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of the button.""" - registry = er.async_get(hass) - await init_integration(hass) state = hass.states.get("button.nettigo_air_monitor_restart") @@ -21,7 +19,7 @@ async def test_button(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART - entry = registry.async_get("button.nettigo_air_monitor_restart") + entry = entity_registry.async_get("button.nettigo_air_monitor_restart") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-restart" diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index dbd1c152d6b..63034d5b075 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -93,11 +93,11 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: +async def test_remove_air_quality_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test remove air_quality entities from registry.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( AIR_QUALITY_PLATFORM, DOMAIN, "aa:bb:cc:dd:ee:ff-sds011", @@ -105,7 +105,7 @@ async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: disabled_by=None, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( AIR_QUALITY_PLATFORM, DOMAIN, "aa:bb:cc:dd:ee:ff-sps30", @@ -115,8 +115,8 @@ async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("air_quality.nettigo_air_monitor_sds011") + entry = entity_registry.async_get("air_quality.nettigo_air_monitor_sds011") assert entry is None - entry = registry.async_get("air_quality.nettigo_air_monitor_sps30") + entry = entity_registry.async_get("air_quality.nettigo_air_monitor_sps30") assert entry is None diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index 4f1b95ea206..50cf3aba659 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -35,11 +35,9 @@ from . import INCOMPLETE_NAM_DATA, init_integration, nam_data from tests.common import async_fire_time_changed -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of the air_quality.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff-signal", @@ -47,7 +45,7 @@ async def test_sensor(hass: HomeAssistant) -> None: disabled_by=None, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff-uptime", @@ -67,7 +65,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.nettigo_air_monitor_bme280_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bme280_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_humidity" @@ -78,7 +76,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_bme280_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bme280_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_temperature" @@ -89,7 +87,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA - entry = registry.async_get("sensor.nettigo_air_monitor_bme280_pressure") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bme280_pressure") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_pressure" @@ -100,7 +98,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_bmp180_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bmp180_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp180_temperature" @@ -111,7 +109,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA - entry = registry.async_get("sensor.nettigo_air_monitor_bmp180_pressure") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bmp180_pressure") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp180_pressure" @@ -122,7 +120,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_bmp280_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bmp280_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp280_temperature" @@ -133,7 +131,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA - entry = registry.async_get("sensor.nettigo_air_monitor_bmp280_pressure") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bmp280_pressure") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp280_pressure" @@ -144,7 +142,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.nettigo_air_monitor_sht3x_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sht3x_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sht3x_humidity" @@ -155,7 +153,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_sht3x_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sht3x_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sht3x_temperature" @@ -166,7 +164,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_humidity" @@ -177,7 +175,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_temperature" @@ -188,7 +186,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.nettigo_air_monitor_heca_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_heca_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-heca_humidity" @@ -199,7 +197,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_heca_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_heca_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-heca_temperature" @@ -213,7 +211,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == SIGNAL_STRENGTH_DECIBELS_MILLIWATT ) - entry = registry.async_get("sensor.nettigo_air_monitor_signal_strength") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_signal_strength") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-signal" @@ -226,7 +224,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.nettigo_air_monitor_uptime") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_uptime") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-uptime" @@ -245,7 +243,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ] assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_pmsx003_common_air_quality_index_level" ) assert entry @@ -259,7 +257,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "19" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_pmsx003_common_air_quality_index" ) assert entry @@ -275,7 +273,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm10") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p1" @@ -289,7 +287,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm2_5") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p2" @@ -303,7 +301,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm1") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm1") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p0" @@ -317,7 +315,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sds011_pm10") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sds011_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011_p1" @@ -328,7 +326,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "19" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_sds011_common_air_quality_index" ) assert entry @@ -349,7 +347,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ] assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_sds011_common_air_quality_index_level" ) assert entry @@ -366,7 +364,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sds011_pm2_5") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sds011_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011_p2" @@ -375,7 +373,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "54" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_sps30_common_air_quality_index" ) assert entry @@ -396,7 +394,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ] assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_sps30_common_air_quality_index_level" ) assert entry @@ -413,7 +411,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm1") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sps30_pm1") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p0" @@ -427,7 +425,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm10") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sps30_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p1" @@ -441,7 +439,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm2_5") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sps30_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p2" @@ -455,7 +453,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm4") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sps30_pm4") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p4" @@ -468,24 +466,27 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_MILLION ) - entry = registry.async_get("sensor.nettigo_air_monitor_mh_z14a_carbon_dioxide") + entry = entity_registry.async_get( + "sensor.nettigo_air_monitor_mh_z14a_carbon_dioxide" + ) assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-mhz14a_carbon_dioxide" -async def test_sensor_disabled(hass: HomeAssistant) -> None: +async def test_sensor_disabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test sensor disabled by default.""" await init_integration(hass) - registry = er.async_get(hass) - entry = registry.async_get("sensor.nettigo_air_monitor_signal_strength") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_signal_strength") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-signal" assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity( + updated_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) @@ -574,11 +575,11 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: assert mock_get_data.call_count == 1 -async def test_unique_id_migration(hass: HomeAssistant) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the unique_id migration.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff-temperature", @@ -586,7 +587,7 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: disabled_by=None, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff-humidity", @@ -596,10 +597,10 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_temperature" - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_humidity" From ac0c1d12c36ae0cfe51100fec0436302125e849e Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 15 Nov 2023 10:45:35 +0100 Subject: [PATCH 502/982] Add test checking ZHA light restores with `None` attributes (#102806) * Add ZHA test checking light restores with None attributes * Move shared `core_rs` fixture to `conftest.py` * Remove special `color_mode` case, use `parametrize` for expected state --------- Co-authored-by: Joost Lekkerkerker --- tests/components/zha/conftest.py | 34 ++++++++++ tests/components/zha/test_binary_sensor.py | 34 ---------- tests/components/zha/test_light.py | 78 +++++++++++++++++++++- 3 files changed, 111 insertions(+), 35 deletions(-) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 9d9d74e72df..a4ff5a3b205 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -26,7 +26,9 @@ import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device from homeassistant.components.zha.core.helpers import get_zha_gateway +from homeassistant.helpers import restore_state from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from .common import patch_cluster as common_patch_cluster @@ -498,3 +500,35 @@ def network_backup() -> zigpy.backups.NetworkBackup: }, } ) + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, state, attributes={}): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "attributes": attributes, + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index b41499dada7..5dd7a5653ec 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -9,8 +9,6 @@ import zigpy.zcl.clusters.security as security from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import restore_state -from homeassistant.util import dt as dt_util from .common import ( async_enable_traffic, @@ -152,38 +150,6 @@ async def test_binary_sensor( assert hass.states.get(entity_id).state == STATE_OFF -@pytest.fixture -def core_rs(hass_storage): - """Core.restore_state fixture.""" - - def _storage(entity_id, attributes, state): - now = dt_util.utcnow().isoformat() - - hass_storage[restore_state.STORAGE_KEY] = { - "version": restore_state.STORAGE_VERSION, - "key": restore_state.STORAGE_KEY, - "data": [ - { - "state": { - "entity_id": entity_id, - "state": str(state), - "attributes": attributes, - "last_changed": now, - "last_updated": now, - "context": { - "id": "3c2243ff5f30447eb12e7348cfd5b8ff", - "user_id": None, - }, - }, - "last_seen": now, - } - ], - } - return - - return _storage - - @pytest.mark.parametrize( "restored_state", [ diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 1ec70b74735..bd799187a19 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -40,7 +40,10 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from tests.common import async_fire_time_changed +from tests.common import ( + async_fire_time_changed, + async_mock_load_restore_state_from_storage, +) IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e9" @@ -1921,3 +1924,76 @@ async def test_group_member_assume_state( await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None assert entity_registry.async_get(group_entity_id) is None + + +@pytest.mark.parametrize( + ("restored_state", "expected_state"), + [ + ( + STATE_ON, + { + "brightness": None, + "off_with_transition": None, + "off_brightness": None, + "color_mode": ColorMode.XY, # color_mode defaults to what the light supports when restored with ON state + "color_temp": None, + "xy_color": None, + "hs_color": None, + "effect": None, + }, + ), + ( + STATE_OFF, + { + "brightness": None, + "off_with_transition": None, + "off_brightness": None, + "color_mode": None, + "color_temp": None, + "xy_color": None, + "hs_color": None, + "effect": None, + }, + ), + ], +) +async def test_restore_light_state( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, + restored_state, + expected_state, +) -> None: + """Test ZHA light restores without throwing an error when attributes are None.""" + + # restore state with None values + attributes = { + "brightness": None, + "off_with_transition": None, + "off_brightness": None, + "color_mode": None, + "color_temp": None, + "xy_color": None, + "hs_color": None, + "effect": None, + } + + entity_id = "light.fakemanufacturer_fakemodel_light" + core_rs( + entity_id, + state=restored_state, + attributes=attributes, + ) + await async_mock_load_restore_state_from_storage(hass) + + zigpy_device = zigpy_device_mock(LIGHT_COLOR) + zha_device = await zha_device_restored(zigpy_device) + entity_id = find_entity_id(Platform.LIGHT, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == restored_state + + # compare actual restored state to expected state + for attribute, expected_value in expected_state.items(): + assert hass.states.get(entity_id).attributes.get(attribute) == expected_value From dd7670cacfe4b8b0a715535f87b29e783c0d18f2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Nov 2023 10:47:05 +0100 Subject: [PATCH 503/982] Improve errors for component configuration with missing keys (#103982) --- homeassistant/config.py | 6 +++ tests/components/rest/test_switch.py | 5 +- .../template/test_alarm_control_panel.py | 2 +- tests/helpers/test_check_config.py | 2 +- tests/snapshots/test_config.ambr | 48 +++++++++---------- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index d39267f6f7d..1a4342c2e87 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -593,6 +593,7 @@ def stringify_invalid( - Prefix with domain, file and line of the error - Suffix with a link to the documentation - Give a more user friendly output for unknown options + - Give a more user friendly output for missing options """ message_prefix = f"Invalid config for [{domain}]" if domain != CONF_CORE and link: @@ -607,6 +608,11 @@ def stringify_invalid( f"{message_prefix}: '{ex.path[-1]}' is an invalid option for [{domain}], " f"check: {path}{message_suffix}" ) + if ex.error_message == "required key not provided": + return ( + f"{message_prefix}: required key '{ex.path[-1]}' not provided" + f"{message_suffix}." + ) # This function is an alternative to the stringification done by # vol.Invalid.__str__, so we need to call Exception.__str__ here # instead of str(ex) diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index d57cd41aa10..79dba7844fd 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -61,7 +61,10 @@ async def test_setup_missing_config( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) - assert "Invalid config for [switch.rest]: required key not provided" in caplog.text + assert ( + "Invalid config for [switch.rest]: required key 'resource' not provided" + in caplog.text + ) async def test_setup_missing_schema( diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index d04757fb808..ef2390680b6 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -204,7 +204,7 @@ async def test_optimistic_states(hass: HomeAssistant, start_ha) -> None: { "alarm_control_panel": {"platform": "template"}, }, - "required key not provided 'panels'", + "required key 'panels' not provided", ), ( { diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 11f65fa4e5e..6e7245603b6 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -288,7 +288,7 @@ async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: ( "blah:\n - paltfrom: test\n", 1, - "required key not provided", + "required key 'platform' not provided", {"paltfrom": "test"}, ), ], diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index fcd3927a891..e116a1255be 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,19 +1,19 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided 'platform', got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key 'platform' not provided.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key 'option1' not provided. Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key 'host' not provided.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key not provided 'adr_0007_5->host', got None. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key 'host' not provided. Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. ''', @@ -21,19 +21,19 @@ # --- # name: test_component_config_validation_error[basic_include] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key not provided 'platform', got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key 'platform' not provided.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 17: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 17: required key 'option1' not provided. Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123. ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key 'host' not provided.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 6: required key not provided 'adr_0007_5->host', got None. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 6: required key 'host' not provided. Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo'. ''', @@ -41,11 +41,11 @@ # --- # name: test_component_config_validation_error[include_dir_list] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key not provided 'platform', got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key 'platform' not provided.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 5: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided. Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123. ''', @@ -53,11 +53,11 @@ # --- # name: test_component_config_validation_error[include_dir_merge_list] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key not provided 'platform', got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key 'platform' not provided.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 12: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided. Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123. ''', @@ -65,19 +65,19 @@ # --- # name: test_component_config_validation_error[packages] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key not provided 'platform', got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key 'platform' not provided.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 29: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 29: required key 'option1' not provided. Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 31: expected str for dictionary value 'option2', got 123. ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: required key 'host' not provided.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 48: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 54: required key not provided 'adr_0007_5->host', got None. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 54: required key 'host' not provided. Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo'. ''', @@ -85,19 +85,19 @@ # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key not provided 'platform', got None.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key 'platform' not provided.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 18: required key not provided 'option1', got None. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 18: required key 'option1' not provided. Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123. ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key not provided 'adr_0007_2->host', got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key 'host' not provided.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 5: required key not provided 'adr_0007_5->host', got None. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 5: required key 'host' not provided. Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo'. ''', @@ -105,19 +105,19 @@ # --- # name: test_component_config_validation_error_with_docs[basic] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key not provided 'platform', got None. Please check the docs at https://www.home-assistant.io/integrations/iot_domain.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key 'platform' not provided. Please check the docs at https://www.home-assistant.io/integrations/iot_domain.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007.", "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key not provided 'option1', got None. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key 'option1' not provided. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key not provided 'adr_0007_2->host', got None. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_2.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_2.", "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_3.", "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key not provided 'adr_0007_5->host', got None. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. ''', From bb456464aef72d7f16bfb492d5211afe9bebea9c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 15 Nov 2023 10:51:12 +0100 Subject: [PATCH 504/982] Fix device tracker see gps accuracy selector (#104022) --- homeassistant/components/device_tracker/services.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index 08ccbcf0b5a..3199dfd8af1 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -25,9 +25,9 @@ see: gps_accuracy: selector: number: - min: 1 - max: 100 - unit_of_measurement: "%" + min: 0 + mode: box + unit_of_measurement: "m" battery: selector: number: From bba2734a5cfb6a4a8a5a1dcc4a0cc21bd9bf02fc Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Wed, 15 Nov 2023 10:01:52 +0000 Subject: [PATCH 505/982] Correct typo in evohome service call description (#103986) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- homeassistant/components/evohome/services.yaml | 2 ++ homeassistant/components/evohome/strings.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index a16395ad6c0..60dcf37ebb0 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -24,7 +24,9 @@ set_system_mode: object: reset_system: + refresh_system: + set_zone_override: fields: entity_id: diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index aa38ee170a5..9e88c9bb031 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -6,7 +6,7 @@ "fields": { "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Mode to set thermostat." + "description": "Mode to set the system to." }, "period": { "name": "Period", From 5f13faac76419b2c0f278cd615a3bb1535b55306 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 15 Nov 2023 02:41:29 -0800 Subject: [PATCH 506/982] Add the todo.get_items service (#103285) --- homeassistant/components/todo/__init__.py | 30 +++++++++- homeassistant/components/todo/services.yaml | 15 +++++ homeassistant/components/todo/strings.json | 10 ++++ tests/components/todo/test_init.py | 65 ++++++++++++++++++++- 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 968256ce3d9..1bd050b0872 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -11,7 +11,7 @@ from homeassistant.components import frontend, websocket_api from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -58,7 +58,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), vol.Optional("rename"): vol.All(cv.string, vol.Length(min=1)), vol.Optional("status"): vol.In( - {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} + {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED}, ), } ), @@ -77,6 +77,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _async_remove_todo_items, required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], ) + component.async_register_entity_service( + "get_items", + cv.make_entity_service_schema( + { + vol.Optional("status"): vol.All( + cv.ensure_list, + [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})], + ), + } + ), + _async_get_todo_items, + supports_response=SupportsResponse.ONLY, + ) await component.async_setup(config) return True @@ -258,3 +271,16 @@ async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> raise ValueError(f"Unable to find To-do item '{item}") uids.append(found.uid) await entity.async_delete_todo_items(uids=uids) + + +async def _async_get_todo_items( + entity: TodoListEntity, call: ServiceCall +) -> dict[str, Any]: + """Return items in the To-do list.""" + return { + "items": [ + dataclasses.asdict(item) + for item in entity.todo_items or () + if not (statuses := call.data.get("status")) or item.status in statuses + ] + } diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 1bdb8aca779..2030229f8d9 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -1,3 +1,18 @@ +get_items: + target: + entity: + domain: todo + fields: + status: + example: "needs_action" + default: needs_action + selector: + select: + translation_key: status + options: + - needs_action + - completed + multiple: true add_item: target: entity: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 6ba8aaba1a5..30058b28c56 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -6,6 +6,16 @@ } }, "services": { + "get_items": { + "name": "Get to-do list items", + "description": "Get items on a to-do list.", + "fields": { + "status": { + "name": "Status", + "description": "Only return to-do items with the specified statuses. Returns not completed actions by default." + } + } + }, "add_item": { "name": "Add to-do list item", "description": "Add a new to-do list item.", diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 33f9af2b0c5..e6d4a8d1d06 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -33,6 +33,16 @@ from tests.common import ( from tests.typing import WebSocketGenerator TEST_DOMAIN = "test" +ITEM_1 = { + "uid": "1", + "summary": "Item #1", + "status": "needs_action", +} +ITEM_2 = { + "uid": "2", + "summary": "Item #2", + "status": "completed", +} class MockFlow(ConfigFlow): @@ -182,12 +192,63 @@ async def test_list_todo_items( assert resp.get("success") assert resp.get("result") == { "items": [ - {"summary": "Item #1", "uid": "1", "status": "needs_action"}, - {"summary": "Item #2", "uid": "2", "status": "completed"}, + ITEM_1, + ITEM_2, ] } +@pytest.mark.parametrize( + ("service_data", "expected_items"), + [ + ({}, [ITEM_1, ITEM_2]), + ( + [ + {"status": [TodoItemStatus.COMPLETED, TodoItemStatus.NEEDS_ACTION]}, + [ITEM_1, ITEM_2], + ] + ), + ( + [ + {"status": [TodoItemStatus.NEEDS_ACTION]}, + [ITEM_1], + ] + ), + ( + [ + {"status": [TodoItemStatus.COMPLETED]}, + [ITEM_2], + ] + ), + ], +) +async def test_get_items_service( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, + service_data: dict[str, Any], + expected_items: list[dict[str, Any]], +) -> None: + """Test listing items in a To-do list from a service call.""" + + await create_mock_platform(hass, [test_entity]) + + state = hass.states.get("todo.entity1") + assert state + assert state.state == "1" + assert state.attributes == {"supported_features": 15} + + result = await hass.services.async_call( + DOMAIN, + "get_items", + service_data, + target={"entity_id": "todo.entity1"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.entity1": {"items": expected_items}} + + async def test_unsupported_websocket( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 23ac3248e620469f3157b18c13cb27c3815368c2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 15 Nov 2023 12:03:15 +0100 Subject: [PATCH 507/982] Remove Discovergy entity description required fields mixin (#104028) --- homeassistant/components/discovergy/sensor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 0f5ace28dd7..c0c610fa98a 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -30,9 +30,9 @@ from .const import DOMAIN, MANUFACTURER PARALLEL_UPDATES = 1 -@dataclass -class DiscovergyMixin: - """Mixin for alternative keys.""" +@dataclass(kw_only=True) +class DiscovergySensorEntityDescription(SensorEntityDescription): + """Class to describe a Discovergy sensor entity.""" value_fn: Callable[[Reading, str, int], datetime | float | None] = field( default=lambda reading, key, scale: float(reading.values[key] / scale) @@ -41,11 +41,6 @@ class DiscovergyMixin: scale: int = field(default_factory=lambda: 1000) -@dataclass -class DiscovergySensorEntityDescription(DiscovergyMixin, SensorEntityDescription): - """Define Sensor entity description class.""" - - GAS_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( DiscovergySensorEntityDescription( key="volume", From c4bf8f96ddbe52344c76a4353d99df77f51d66e0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Nov 2023 13:11:33 +0100 Subject: [PATCH 508/982] Add tests for components with custom validators (#104024) * Add tests for components with custom validators * Address review comments --- .../basic/configuration.yaml | 13 + .../basic_include/configuration.yaml | 4 + .../integrations/custom_validator_bad_1.yaml | 1 + .../integrations/custom_validator_bad_2.yaml | 1 + .../integrations/custom_validator_ok_1.yaml | 2 + .../integrations/custom_validator_ok_2.yaml | 1 + .../packages/configuration.yaml | 14 + .../integrations/custom_validator_bad_1.yaml | 2 + .../integrations/custom_validator_bad_2.yaml | 2 + .../integrations/custom_validator_ok_1.yaml | 3 + .../integrations/custom_validator_ok_2.yaml | 2 + tests/snapshots/test_config.ambr | 344 ++++++++++++++---- tests/test_config.py | 62 +++- 13 files changed, 368 insertions(+), 83 deletions(-) create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_1.yaml create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_2.yaml create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_1.yaml create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_2.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_1.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_2.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_1.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml index 158a32a7d69..9c3d1eb190b 100644 --- a/tests/fixtures/core/config/component_validation/basic/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -43,3 +43,16 @@ adr_0007_4: adr_0007_5: no_such_option: foo port: foo + +# This is correct and should not generate errors +custom_validator_ok_1: + host: blah.com + +# Host is missing +custom_validator_ok_2: + +# This always raises HomeAssistantError +custom_validator_bad_1: + +# This always raises ValueError +custom_validator_bad_2: diff --git a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml index d67ae673901..5744e3005fa 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -4,3 +4,7 @@ adr_0007_2: !include integrations/adr_0007_2.yaml adr_0007_3: !include integrations/adr_0007_3.yaml adr_0007_4: !include integrations/adr_0007_4.yaml adr_0007_5: !include integrations/adr_0007_5.yaml +custom_validator_ok_1: !include integrations/custom_validator_ok_1.yaml +custom_validator_ok_2: !include integrations/custom_validator_ok_2.yaml +custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml +custom_validator_bad_2: !include integrations/custom_validator_bad_2.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_1.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_1.yaml new file mode 100644 index 00000000000..12d6d869f35 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_1.yaml @@ -0,0 +1 @@ +# This always raises HomeAssistantError diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_2.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_2.yaml new file mode 100644 index 00000000000..7af4b20c016 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_2.yaml @@ -0,0 +1 @@ +# This always raises ValueError diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_1.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_1.yaml new file mode 100644 index 00000000000..d246d73c257 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_1.yaml @@ -0,0 +1,2 @@ +# This is correct and should not generate errors +host: blah.com diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_2.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_2.yaml new file mode 100644 index 00000000000..8b592b01e2d --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_2.yaml @@ -0,0 +1 @@ +# Host is missing diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml index dff25efd749..b8116b5988e 100644 --- a/tests/fixtures/core/config/component_validation/packages/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -54,3 +54,17 @@ homeassistant: adr_0007_5: no_such_option: foo port: foo + + pack_custom_validator_ok_1: + # This is correct and should not generate errors + custom_validator_ok_1: + host: blah.com + pack_custom_validator_ok_2: + # Host is missing + custom_validator_ok_2: + pack_custom_validator_bad_1: + # This always raises HomeAssistantError + custom_validator_bad_1: + pack_custom_validator_bad_2: + # This always raises ValueError + custom_validator_bad_2: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_1.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_1.yaml new file mode 100644 index 00000000000..2e17b766800 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_1.yaml @@ -0,0 +1,2 @@ +# This always raises HomeAssistantError +custom_validator_bad_1: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_2.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_2.yaml new file mode 100644 index 00000000000..213c3ea03f8 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_2.yaml @@ -0,0 +1,2 @@ +# This always raises ValueError +custom_validator_bad_2: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_1.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_1.yaml new file mode 100644 index 00000000000..257ff66d10b --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_1.yaml @@ -0,0 +1,3 @@ +# This is correct and should not generate errors +custom_validator_ok_1: + host: blah.com diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml new file mode 100644 index 00000000000..59a240defaf --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml @@ -0,0 +1,2 @@ +# Host is missing +custom_validator_ok_2: diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index e116a1255be..9df27e02f90 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,106 +1,290 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key 'platform' not provided.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. - ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key 'host' not provided.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", - "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", - ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key 'host' not provided. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. - ''', + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key 'platform' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key 'host' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key 'host' not provided. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 52: required key 'host' not provided.", + }), + dict({ + 'has_exc_info': True, + 'message': 'Invalid config for [custom_validator_bad_1]: broken', + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), ]) # --- # name: test_component_config_validation_error[basic_include] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key 'platform' not provided.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 17: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123. - ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key 'host' not provided.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", - "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", - ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 6: required key 'host' not provided. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo'. - ''', + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key 'platform' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 17: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123. + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key 'host' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 6: required key 'host' not provided. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 8: required key 'host' not provided.", + }), + dict({ + 'has_exc_info': True, + 'message': 'Invalid config for [custom_validator_bad_1]: broken', + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), ]) # --- # name: test_component_config_validation_error[include_dir_list] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key 'platform' not provided.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123. - ''', + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key 'platform' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123. + ''', + }), + dict({ + 'has_exc_info': True, + 'message': 'Invalid config for [custom_validator_bad_1]: broken', + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key 'platform' not provided.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123. - ''', + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key 'platform' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123. + ''', + }), + dict({ + 'has_exc_info': True, + 'message': 'Invalid config for [custom_validator_bad_1]: broken', + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), ]) # --- # name: test_component_config_validation_error[packages] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key 'platform' not provided.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 29: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 31: expected str for dictionary value 'option2', got 123. - ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: required key 'host' not provided.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", - "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 48: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", - ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 54: required key 'host' not provided. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo'. - ''', + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key 'platform' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 29: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 31: expected str for dictionary value 'option2', got 123. + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: required key 'host' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 48: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 54: required key 'host' not provided. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 64: required key 'host' not provided.", + }), + dict({ + 'has_exc_info': True, + 'message': 'Invalid config for [custom_validator_bad_1]: broken', + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key 'platform' not provided.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", - ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 18: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123. - ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key 'host' not provided.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", - "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", - ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 5: required key 'host' not provided. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo'. - ''', + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key 'platform' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 18: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123. + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key 'host' not provided.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 5: required key 'host' not provided. + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml, line 2: required key 'host' not provided.", + }), + dict({ + 'has_exc_info': True, + 'message': 'Invalid config for [custom_validator_bad_1]: broken', + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), ]) # --- # name: test_component_config_validation_error_with_docs[basic] diff --git a/tests/test_config.py b/tests/test_config.py index ca6fb3b52bc..2abb2f08d60 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -33,6 +33,7 @@ from homeassistant.core import ConfigSource, HomeAssistant, HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir import homeassistant.helpers.check_config as check_config from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration from homeassistant.util.unit_system import ( _CONF_UNIT_SYSTEM_US_CUSTOMARY, @@ -193,7 +194,7 @@ async def mock_adr_0007_integrations(hass: HomeAssistant) -> list[Integration]: domain: vol.Schema( { vol.Required("host"): str, - vol.Required("port", default=8080): int, + vol.Optional("port", default=8080): int, } ) }, @@ -226,7 +227,7 @@ async def mock_adr_0007_integrations_with_docs( domain: vol.Schema( { vol.Required("host"): str, - vol.Required("port", default=8080): int, + vol.Optional("port", default=8080): int, } ) }, @@ -247,6 +248,53 @@ async def mock_adr_0007_integrations_with_docs( return integrations +@pytest.fixture +async def mock_custom_validator_integrations(hass: HomeAssistant) -> list[Integration]: + """Mock integrations with custom validator.""" + integrations = [] + + for domain in ("custom_validator_ok_1", "custom_validator_ok_2"): + + def gen_async_validate_config(domain): + schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Optional("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + + async def async_validate_config( + hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return schema(config) + + return async_validate_config + + integrations.append(mock_integration(hass, MockModule(domain))) + mock_platform( + hass, + f"{domain}.config", + Mock(async_validate_config=gen_async_validate_config(domain)), + ) + + for domain, exception in [ + ("custom_validator_bad_1", HomeAssistantError("broken")), + ("custom_validator_bad_2", ValueError("broken")), + ]: + integrations.append(mock_integration(hass, MockModule(domain))) + mock_platform( + hass, + f"{domain}.config", + Mock(async_validate_config=AsyncMock(side_effect=exception)), + ) + + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" assert not os.path.isfile(YAML_PATH) @@ -1581,6 +1629,7 @@ async def test_component_config_validation_error( mock_iot_domain_integration: Integration, mock_non_adr_0007_integration: None, mock_adr_0007_integrations: list[Integration], + mock_custom_validator_integrations: list[Integration], snapshot: SnapshotAssertion, ) -> None: """Test schema error in component.""" @@ -1598,6 +1647,10 @@ async def test_component_config_validation_error( "adr_0007_3", "adr_0007_4", "adr_0007_5", + "custom_validator_ok_1", + "custom_validator_ok_2", + "custom_validator_bad_1", + "custom_validator_bad_2", ]: integration = await async_get_integration(hass, domain) await config_util.async_process_component_config( @@ -1607,7 +1660,10 @@ async def test_component_config_validation_error( ) error_records = [ - record.message.replace(base_path, "") + { + "message": record.message.replace(base_path, ""), + "has_exc_info": bool(record.exc_info), + } for record in caplog.get_records("call") if record.levelno == logging.ERROR ] From 194104f5f89451dd9c39aec43747a7ead19c1ec5 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 15 Nov 2023 13:14:12 +0100 Subject: [PATCH 509/982] Remove CO2Signal entity description required fields mixin (#104031) --- homeassistant/components/co2signal/sensor.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 6f0053d3be4..00051d8bec9 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta from aioelectricitymaps.models import CarbonIntensityResponse @@ -22,18 +21,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN from .coordinator import CO2SignalCoordinator -SCAN_INTERVAL = timedelta(minutes=3) - -@dataclass -class ElectricityMapsMixin: - """Mixin for value and unit_of_measurement_fn function.""" - - value_fn: Callable[[CarbonIntensityResponse], float | None] - - -@dataclass -class CO2SensorEntityDescription(SensorEntityDescription, ElectricityMapsMixin): +@dataclass(kw_only=True) +class CO2SensorEntityDescription(SensorEntityDescription): """Provide a description of a CO2 sensor.""" # For backwards compat, allow description to override unique ID key to use @@ -41,6 +31,7 @@ class CO2SensorEntityDescription(SensorEntityDescription, ElectricityMapsMixin): unit_of_measurement_fn: Callable[ [CarbonIntensityResponse], str | None ] | None = None + value_fn: Callable[[CarbonIntensityResponse], float | None] SENSORS = ( From 0e04cd6b35a30acc08f95a399c4253a6bc9990da Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 15 Nov 2023 13:15:31 +0100 Subject: [PATCH 510/982] Add reauth flow to Trafikverket Weatherstation (#104027) * Add reauth flow to Trafikverket Weatherstation * Add tests --- .../config_flow.py | 49 ++++++++- .../trafikverket_weatherstation/strings.json | 8 +- .../test_config_flow.py | 102 ++++++++++++++++++ 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index f8f86298045..89cbd373665 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -1,6 +1,9 @@ """Adds config flow for Trafikverket Weather integration.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from pytrafikverket.exceptions import ( InvalidAuthentication, MultipleWeatherStationsFound, @@ -23,7 +26,7 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - entry: config_entries.ConfigEntry + entry: config_entries.ConfigEntry | None = None async def validate_input(self, sensor_api: str, station: str) -> None: """Validate input from user input.""" @@ -71,3 +74,47 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with Trafikverket.""" + + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm re-authentication with Trafikverket.""" + errors: dict[str, str] = {} + + if user_input: + api_key = user_input[CONF_API_KEY] + + assert self.entry is not None + + try: + await self.validate_input(api_key, self.entry.data[CONF_STATION]) + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoWeatherStationFound: + errors["base"] = "invalid_station" + except MultipleWeatherStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_API_KEY: api_key, + }, + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): cv.string}), + errors=errors, + ) diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json index 9ff1b077f33..e7e279ba2d5 100644 --- a/homeassistant/components/trafikverket_weatherstation/strings.json +++ b/homeassistant/components/trafikverket_weatherstation/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +16,11 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "station": "Station" } + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } } } }, diff --git a/tests/components/trafikverket_weatherstation/test_config_flow.py b/tests/components/trafikverket_weatherstation/test_config_flow.py index 36c30b33b53..e55e04d8411 100644 --- a/tests/components/trafikverket_weatherstation/test_config_flow.py +++ b/tests/components/trafikverket_weatherstation/test_config_flow.py @@ -15,6 +15,8 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + DOMAIN = "trafikverket_weatherstation" CONF_STATION = "station" @@ -97,3 +99,103 @@ async def test_flow_fails( ) assert result4["errors"] == {"base": base_error} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", + ), patch( + "homeassistant.components.trafikverket_weatherstation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == {"api_key": "1234567891", "station": "Vallby"} + + +@pytest.mark.parametrize( + ("side_effect", "base_error"), + [ + ( + InvalidAuthentication, + "invalid_auth", + ), + ( + NoWeatherStationFound, + "invalid_station", + ), + ( + MultipleWeatherStationsFound, + "more_stations", + ), + ( + Exception, + "cannot_connect", + ), + ], +) +async def test_reauth_flow_fails( + hass: HomeAssistant, side_effect: Exception, base_error: str +) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", + side_effect=side_effect(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": base_error} From b4e8243e1803be1528c28c404798ff9de17cf8bd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 15 Nov 2023 13:18:20 +0100 Subject: [PATCH 511/982] Refactor tests for discovergy (#103667) --- tests/components/discovergy/conftest.py | 50 ++++++++++++++----- tests/components/discovergy/const.py | 31 ++++++++++++ .../snapshots/test_diagnostics.ambr | 28 +++++++++++ .../components/discovergy/test_config_flow.py | 27 +++++++--- .../components/discovergy/test_diagnostics.py | 17 ++----- 5 files changed, 120 insertions(+), 33 deletions(-) diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py index ea0fe84852f..b3a452e36e5 100644 --- a/tests/components/discovergy/conftest.py +++ b/tests/components/discovergy/conftest.py @@ -1,33 +1,59 @@ """Fixtures for Discovergy integration tests.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch +from pydiscovergy import Discovergy +from pydiscovergy.models import Reading import pytest from homeassistant.components.discovergy import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.discovergy.const import GET_METERS +from tests.components.discovergy.const import GET_METERS, LAST_READING, LAST_READING_GAS -@pytest.fixture -def mock_meters() -> Mock: - """Patch libraries.""" - with patch("pydiscovergy.Discovergy.meters") as discovergy: - discovergy.side_effect = AsyncMock(return_value=GET_METERS) - yield discovergy +def _meter_last_reading(meter_id: str) -> Reading: + """Side effect function for Discovergy mock.""" + return ( + LAST_READING_GAS + if meter_id == "d81a652fe0824f9a9d336016587d3b9d" + else LAST_READING + ) -@pytest.fixture +@pytest.fixture(name="discovergy") +def mock_discovergy() -> None: + """Mock the pydiscovergy client.""" + mock = AsyncMock(spec=Discovergy) + mock.meters.return_value = GET_METERS + mock.meter_last_reading.side_effect = _meter_last_reading + + with patch( + "homeassistant.components.discovergy.pydiscovergy.Discovergy", + return_value=mock, + ): + yield mock + + +@pytest.fixture(name="config_entry") async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return a MockConfigEntry for testing.""" - entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, title="user@example.org", unique_id="user@example.org", data={CONF_EMAIL: "user@example.org", CONF_PASSWORD: "supersecretpassword"}, ) - entry.add_to_hass(hass) - return entry + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, discovergy: AsyncMock +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/discovergy/const.py b/tests/components/discovergy/const.py index 5c233d50ba8..6c5428741af 100644 --- a/tests/components/discovergy/const.py +++ b/tests/components/discovergy/const.py @@ -30,6 +30,32 @@ GET_METERS = [ "last_measurement_time": 1678430543742, }, ), + Meter( + meter_id="d81a652fe0824f9a9d336016587d3b9d", + serial_number="def456", + full_serial_number="def456", + type="PIP", + measurement_type="GAS", + load_profile_type="SLP", + location=Location( + zip=12345, + city="Testhause", + street="Teststraße", + street_number="1", + country="Germany", + ), + additional={ + "manufacturer_id": "TST", + "printed_full_serial_number": "def456", + "administration_number": "12345", + "scaling_factor": 1, + "current_scaling_factor": 1, + "voltage_scaling_factor": 1, + "internal_meters": 1, + "first_measurement_time": 1517569090926, + "last_measurement_time": 1678430543742, + }, + ), ] LAST_READING = Reading( @@ -50,3 +76,8 @@ LAST_READING = Reading( "voltage3": 239000.0, }, ) + +LAST_READING_GAS = Reading( + time=datetime.datetime(2023, 3, 10, 7, 32, 6, 702000), + values={"actualityDuration": 52000.0, "storageNumber": 0.0, "volume": 21064800.0}, +) diff --git a/tests/components/discovergy/snapshots/test_diagnostics.ambr b/tests/components/discovergy/snapshots/test_diagnostics.ambr index d02f57c7540..2a7dd6903af 100644 --- a/tests/components/discovergy/snapshots/test_diagnostics.ambr +++ b/tests/components/discovergy/snapshots/test_diagnostics.ambr @@ -22,8 +22,36 @@ 'serial_number': '**REDACTED**', 'type': 'TST', }), + dict({ + 'additional': dict({ + 'administration_number': '**REDACTED**', + 'current_scaling_factor': 1, + 'first_measurement_time': 1517569090926, + 'internal_meters': 1, + 'last_measurement_time': 1678430543742, + 'manufacturer_id': 'TST', + 'printed_full_serial_number': '**REDACTED**', + 'scaling_factor': 1, + 'voltage_scaling_factor': 1, + }), + 'full_serial_number': '**REDACTED**', + 'load_profile_type': 'SLP', + 'location': '**REDACTED**', + 'measurement_type': 'GAS', + 'meter_id': 'd81a652fe0824f9a9d336016587d3b9d', + 'serial_number': '**REDACTED**', + 'type': 'PIP', + }), ]), 'readings': dict({ + 'd81a652fe0824f9a9d336016587d3b9d': dict({ + 'time': '2023-03-10T07:32:06.702000', + 'values': dict({ + 'actualityDuration': 52000.0, + 'storageNumber': 0.0, + 'volume': 21064800.0, + }), + }), 'f8d610b7a8cc4e73939fa33b990ded54': dict({ 'time': '2023-03-10T07:32:06.702000', 'values': dict({ diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 08e9df06978..16ba3a1546e 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Discovergy config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin import pytest @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry from tests.components.discovergy.const import GET_METERS -async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: +async def test_form(hass: HomeAssistant, discovergy: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -25,7 +25,10 @@ async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: with patch( "homeassistant.components.discovergy.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.discovergy.config_flow.pydiscovergy.Discovergy", + return_value=discovergy, + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -45,12 +48,14 @@ async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: async def test_reauth( - hass: HomeAssistant, mock_meters: Mock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, discovergy: AsyncMock ) -> None: """Test reauth flow.""" + config_entry.add_to_hass(hass) + init_result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": mock_config_entry.unique_id}, + context={"source": SOURCE_REAUTH, "unique_id": config_entry.unique_id}, data=None, ) @@ -60,7 +65,10 @@ async def test_reauth( with patch( "homeassistant.components.discovergy.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.discovergy.config_flow.pydiscovergy.Discovergy", + return_value=discovergy, + ): configure_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], { @@ -88,7 +96,7 @@ async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> """Test to handle exceptions.""" with patch( - "pydiscovergy.Discovergy.meters", + "homeassistant.components.discovergy.config_flow.pydiscovergy.Discovergy.meters", side_effect=error, ): result = await hass.config_entries.flow.async_init( @@ -104,7 +112,10 @@ async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> assert result["step_id"] == "user" assert result["errors"] == {"base": message} - with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS): + with patch( + "homeassistant.components.discovergy.config_flow.pydiscovergy.Discovergy.meters", + return_value=GET_METERS, + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py index d7565e3f0c4..f2db5fb854d 100644 --- a/tests/components/discovergy/test_diagnostics.py +++ b/tests/components/discovergy/test_diagnostics.py @@ -1,31 +1,22 @@ """Test Discovergy diagnostics.""" -from unittest.mock import patch - +import pytest from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.components.discovergy.const import GET_METERS, LAST_READING from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("setup_integration") async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_config_entry: MockConfigEntry, + config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS), patch( - "pydiscovergy.Discovergy.meter_last_reading", return_value=LAST_READING - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - result = await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert result == snapshot From dbe193aaa43b23c0fbd74b7ef715dc795c01902c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 15 Nov 2023 13:36:20 +0100 Subject: [PATCH 512/982] Add `release_url` property of Shelly update entities (#103739) --- homeassistant/components/shelly/const.py | 9 ++++++++ homeassistant/components/shelly/update.py | 26 ++++++++++++++++++++--- homeassistant/components/shelly/utils.py | 11 ++++++++++ tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_update.py | 12 ++++++++++- tests/components/shelly/test_utils.py | 22 +++++++++++++++++++ 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 0275b805208..02dc347e495 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -186,3 +186,12 @@ OTA_BEGIN = "ota_begin" OTA_ERROR = "ota_error" OTA_PROGRESS = "ota_progress" OTA_SUCCESS = "ota_success" + +GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog" +GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" +DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( + "SAWD-0A1XX10EU1", + "SHMOS-01", + "SHMOS-02", + "SHTRV-01", +) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index d4528f55288..9e52a292108 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -34,7 +34,7 @@ from .entity import ( async_setup_entry_rest, async_setup_entry_rpc, ) -from .utils import get_device_entry_gen +from .utils import get_device_entry_gen, get_release_url LOGGER = logging.getLogger(__name__) @@ -156,10 +156,15 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): self, block_coordinator: ShellyBlockCoordinator, attribute: str, - description: RestEntityDescription, + description: RestUpdateDescription, ) -> None: """Initialize update entity.""" super().__init__(block_coordinator, attribute, description) + self._attr_release_url = get_release_url( + block_coordinator.device.gen, + block_coordinator.model, + description.beta, + ) self._in_progress_old_version: str | None = None @property @@ -225,11 +230,14 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): coordinator: ShellyRpcCoordinator, key: str, attribute: str, - description: RpcEntityDescription, + description: RpcUpdateDescription, ) -> None: """Initialize update entity.""" super().__init__(coordinator, key, attribute, description) self._ota_in_progress: bool = False + self._attr_release_url = get_release_url( + coordinator.device.gen, coordinator.model, description.beta + ) async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -336,3 +344,15 @@ class RpcSleepingUpdateEntity( return None return self.last_state.attributes.get(ATTR_LATEST_VERSION) + + @property + def release_url(self) -> str | None: + """URL to the full release notes.""" + if not self.coordinator.device.initialized: + return None + + return get_release_url( + self.coordinator.device.gen, + self.coordinator.model, + self.entity_description.beta, + ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 4d25812361c..eff21e71413 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -26,7 +26,10 @@ from .const import ( BASIC_INPUTS_EVENTS_TYPES, CONF_COAP_PORT, DEFAULT_COAP_PORT, + DEVICES_WITHOUT_FIRMWARE_CHANGELOG, DOMAIN, + GEN1_RELEASE_URL, + GEN2_RELEASE_URL, LOGGER, RPC_INPUTS_EVENTS_TYPES, SHBTN_INPUTS_EVENTS_TYPES, @@ -408,3 +411,11 @@ def mac_address_from_name(name: str) -> str | None: """Convert a name to a mac address.""" mac = name.partition(".")[0].partition("-")[-1] return mac.upper() if len(mac) == 12 else None + + +def get_release_url(gen: int, model: str, beta: bool) -> str | None: + """Return release URL or None.""" + if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: + return None + + return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 438ca9b5ace..8b4ca0824c4 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -281,6 +281,7 @@ async def mock_block_device(): firmware_version="some fw string", initialized=True, model="SHSW-1", + gen=1, ) type(device).name = PropertyMock(return_value="Test name") block_device_mock.return_value = device diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 454afb73ce1..06eac49e293 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -5,11 +5,16 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import ( + DOMAIN, + GEN1_RELEASE_URL, + GEN2_RELEASE_URL, +) from homeassistant.components.update import ( ATTR_IN_PROGRESS, ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, + ATTR_RELEASE_URL, DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateEntityFeature, @@ -75,6 +80,7 @@ async def test_block_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_RELEASE_URL] == GEN1_RELEASE_URL monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2") await mock_rest_update(hass, freezer) @@ -117,6 +123,7 @@ async def test_block_beta_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" assert state.attributes[ATTR_IN_PROGRESS] is False + assert state.attributes[ATTR_RELEASE_URL] is None await hass.services.async_call( UPDATE_DOMAIN, @@ -270,6 +277,7 @@ async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] == 0 + assert state.attributes[ATTR_RELEASE_URL] == GEN2_RELEASE_URL inject_rpc_device_event( monkeypatch, @@ -341,6 +349,7 @@ async def test_rpc_sleeping_update( assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_SUPPORTED_FEATURES] == UpdateEntityFeature(0) + assert state.attributes[ATTR_RELEASE_URL] == GEN2_RELEASE_URL monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() @@ -467,6 +476,7 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "1" assert state.attributes[ATTR_IN_PROGRESS] is False + assert state.attributes[ATTR_RELEASE_URL] is None monkeypatch.setitem( mock_rpc_device.status["sys"], diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 3d273ff3059..cacef1fad71 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -1,12 +1,14 @@ """Tests for Shelly utils.""" import pytest +from homeassistant.components.shelly.const import GEN1_RELEASE_URL, GEN2_RELEASE_URL from homeassistant.components.shelly.utils import ( get_block_channel_name, get_block_device_sleep_period, get_block_input_triggers, get_device_uptime, get_number_of_channels, + get_release_url, get_rpc_channel_name, get_rpc_input_triggers, is_block_momentary_input, @@ -224,3 +226,23 @@ async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: monkeypatch.setattr(mock_rpc_device, "config", {"input:0": {"type": "switch"}}) assert not get_rpc_input_triggers(mock_rpc_device) + + +@pytest.mark.parametrize( + ("gen", "model", "beta", "expected"), + [ + (1, "SHMOS-01", False, None), + (1, "SHSW-1", False, GEN1_RELEASE_URL), + (1, "SHSW-1", True, None), + (2, "SAWD-0A1XX10EU1", False, None), + (2, "SNSW-102P16EU", False, GEN2_RELEASE_URL), + (2, "SNSW-102P16EU", True, None), + ], +) +def test_get_release_url( + gen: int, model: str, beta: bool, expected: str | None +) -> None: + """Test get_release_url() with a device without a release note URL.""" + result = get_release_url(gen, model, beta) + + assert result is expected From c98a3a2fd19bbadb3d140955c192a523673cf3b8 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Wed, 15 Nov 2023 13:39:34 +0100 Subject: [PATCH 513/982] Add support for lupusec smoke and water sensor (#103905) --- homeassistant/components/lupusec/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index c98e634dcb3..ee369baf8dd 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -27,7 +27,7 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - device_types = CONST.TYPE_OPENING + device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR devices = [] for device in data.lupusec.get_devices(generic_type=device_types): From cf3a2741c511d6ce5e095a448d8096e4bbb011e1 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 15 Nov 2023 15:15:48 +0100 Subject: [PATCH 514/982] Bumb python-homewizard-energy to 4.0.0 (#104032) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index b987fd6f208..ab3f4706970 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==3.1.0"], + "requirements": ["python-homewizard-energy==4.0.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 070a4bf6474..57985bbf20d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2143,7 +2143,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==3.1.0 +python-homewizard-energy==4.0.0 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43c79fe8885..b84037a2f84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ python-ecobee-api==0.2.17 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==3.1.0 +python-homewizard-energy==4.0.0 # homeassistant.components.izone python-izone==1.2.9 From c92a90e04d418aacf188d8f4ba7f9625448f9d18 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 15 Nov 2023 15:45:33 +0100 Subject: [PATCH 515/982] Disable options flow for Shelly Wall Display (#103988) --- homeassistant/components/shelly/config_flow.py | 7 +++++-- homeassistant/components/shelly/const.py | 4 +++- tests/components/shelly/test_utils.py | 8 ++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index bad13fde006..6cde265bc25 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -29,6 +29,7 @@ from .const import ( CONF_SLEEP_PERIOD, DOMAIN, LOGGER, + MODEL_WALL_DISPLAY, BLEScannerMode, ) from .coordinator import async_reconnect_soon, get_entry_data @@ -363,8 +364,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" - return config_entry.data.get("gen") == 2 and not config_entry.data.get( - CONF_SLEEP_PERIOD + return ( + config_entry.data.get("gen") == 2 + and not config_entry.data.get(CONF_SLEEP_PERIOD) + and config_entry.data.get("model") != MODEL_WALL_DISPLAY ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 02dc347e495..95ffa2de91e 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -187,10 +187,12 @@ OTA_ERROR = "ota_error" OTA_PROGRESS = "ota_progress" OTA_SUCCESS = "ota_success" +MODEL_WALL_DISPLAY = "SAWD-0A1XX10EU1" + GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog" GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( - "SAWD-0A1XX10EU1", + MODEL_WALL_DISPLAY, "SHMOS-01", "SHMOS-02", "SHTRV-01", diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index cacef1fad71..07ba0d724c2 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -1,7 +1,11 @@ """Tests for Shelly utils.""" import pytest -from homeassistant.components.shelly.const import GEN1_RELEASE_URL, GEN2_RELEASE_URL +from homeassistant.components.shelly.const import ( + GEN1_RELEASE_URL, + GEN2_RELEASE_URL, + MODEL_WALL_DISPLAY, +) from homeassistant.components.shelly.utils import ( get_block_channel_name, get_block_device_sleep_period, @@ -234,7 +238,7 @@ async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: (1, "SHMOS-01", False, None), (1, "SHSW-1", False, GEN1_RELEASE_URL), (1, "SHSW-1", True, None), - (2, "SAWD-0A1XX10EU1", False, None), + (2, MODEL_WALL_DISPLAY, False, None), (2, "SNSW-102P16EU", False, GEN2_RELEASE_URL), (2, "SNSW-102P16EU", True, None), ], From c132900b927aa0750f48b96b76ae8829ccc9af1e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 15 Nov 2023 11:01:20 -0500 Subject: [PATCH 516/982] Add zwave_js.set_lock_configuration service (#103595) * Add zwave_js.set_lock_configuration service * Add tests * string tweaks * Update homeassistant/components/zwave_js/lock.py Co-authored-by: Martin Hjelmare * Update strings.json * Update services.yaml * Update lock.py * Remove handle params --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/const.py | 8 ++ homeassistant/components/zwave_js/lock.py | 74 ++++++++++++- .../components/zwave_js/services.yaml | 59 ++++++++++ .../components/zwave_js/strings.json | 38 +++++++ tests/components/zwave_js/test_lock.py | 103 +++++++++++++++++- 5 files changed, 275 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index acc1da4e51a..656620d01dd 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -99,6 +99,7 @@ SERVICE_REFRESH_VALUE = "refresh_value" SERVICE_RESET_METER = "reset_meter" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" +SERVICE_SET_LOCK_CONFIGURATION = "set_lock_configuration" SERVICE_SET_VALUE = "set_value" ATTR_NODES = "nodes" @@ -118,6 +119,13 @@ ATTR_METER_TYPE_NAME = "meter_type_name" # invoke CC API ATTR_METHOD_NAME = "method_name" ATTR_PARAMETERS = "parameters" +# lock set configuration +ATTR_AUTO_RELOCK_TIME = "auto_relock_time" +ATTR_BLOCK_TO_BLOCK = "block_to_block" +ATTR_HOLD_AND_RELEASE_TIME = "hold_and_release_time" +ATTR_LOCK_TIMEOUT = "lock_timeout" +ATTR_OPERATION_TYPE = "operation_type" +ATTR_TWIST_ASSIST = "twist_assist" ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 5457916a1e1..59faf7fbbb6 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -11,10 +11,12 @@ from zwave_js_server.const.command_class.lock import ( ATTR_USERCODE, LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP, LOCK_CMD_CLASS_TO_PROPERTY_MAP, + DoorLockCCConfigurationSetOptions, DoorLockMode, + OperationType, ) from zwave_js_server.exceptions import BaseZwaveJSServerError -from zwave_js_server.util.lock import clear_usercode, set_usercode +from zwave_js_server.util.lock import clear_usercode, set_configuration, set_usercode from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.config_entries import ConfigEntry @@ -26,10 +28,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ATTR_AUTO_RELOCK_TIME, + ATTR_BLOCK_TO_BLOCK, + ATTR_HOLD_AND_RELEASE_TIME, + ATTR_LOCK_TIMEOUT, + ATTR_OPERATION_TYPE, + ATTR_TWIST_ASSIST, DATA_CLIENT, DOMAIN, LOGGER, SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) from .discovery import ZwaveDiscoveryInfo @@ -47,6 +56,7 @@ STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { STATE_LOCKED: True, }, } +UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) async def async_setup_entry( @@ -92,6 +102,24 @@ async def async_setup_entry( "async_clear_lock_usercode", ) + platform.async_register_entity_service( + SERVICE_SET_LOCK_CONFIGURATION, + { + vol.Required(ATTR_OPERATION_TYPE): vol.All( + cv.string, + vol.Upper, + vol.In(["TIMED", "CONSTANT"]), + lambda x: OperationType[x], + ), + vol.Optional(ATTR_LOCK_TIMEOUT): UNIT16_SCHEMA, + vol.Optional(ATTR_AUTO_RELOCK_TIME): UNIT16_SCHEMA, + vol.Optional(ATTR_HOLD_AND_RELEASE_TIME): UNIT16_SCHEMA, + vol.Optional(ATTR_TWIST_ASSIST): vol.Coerce(bool), + vol.Optional(ATTR_BLOCK_TO_BLOCK): vol.Coerce(bool), + }, + "async_set_lock_configuration", + ) + class ZWaveLock(ZWaveBaseEntity, LockEntity): """Representation of a Z-Wave lock.""" @@ -138,9 +166,10 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): await set_usercode(self.info.node, code_slot, usercode) except BaseZwaveJSServerError as err: raise HomeAssistantError( - f"Unable to set lock usercode on code_slot {code_slot}: {err}" + f"Unable to set lock usercode on lock {self.entity_id} code_slot " + f"{code_slot}: {err}" ) from err - LOGGER.debug("User code at slot %s set", code_slot) + LOGGER.debug("User code at slot %s on lock %s set", code_slot, self.entity_id) async def async_clear_lock_usercode(self, code_slot: int) -> None: """Clear the usercode at index X on the lock.""" @@ -148,6 +177,41 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): await clear_usercode(self.info.node, code_slot) except BaseZwaveJSServerError as err: raise HomeAssistantError( - f"Unable to clear lock usercode on code_slot {code_slot}: {err}" + f"Unable to clear lock usercode on lock {self.entity_id} code_slot " + f"{code_slot}: {err}" ) from err - LOGGER.debug("User code at slot %s cleared", code_slot) + LOGGER.debug( + "User code at slot %s on lock %s cleared", code_slot, self.entity_id + ) + + async def async_set_lock_configuration( + self, + operation_type: OperationType, + lock_timeout: int | None = None, + auto_relock_time: int | None = None, + hold_and_release_time: int | None = None, + twist_assist: bool | None = None, + block_to_block: bool | None = None, + ) -> None: + """Set the lock configuration.""" + params: dict[str, Any] = {"operation_type": operation_type} + for attr, val in ( + ("lock_timeout_configuration", lock_timeout), + ("auto_relock_time", auto_relock_time), + ("hold_and_release_time", hold_and_release_time), + ("twist_assist", twist_assist), + ("block_to_block", block_to_block), + ): + if val is not None: + params[attr] = val + configuration = DoorLockCCConfigurationSetOptions(**params) + result = await set_configuration( + self.info.node.endpoints[self.info.primary_value.endpoint or 0], + configuration, + ) + if result is None: + return + msg = f"Result status is {result.status}" + if result.remaining_duration is not None: + msg += f" and remaining duration is {str(result.remaining_duration)}" + LOGGER.info("%s after setting lock configuration for %s", msg, self.entity_id) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index cb8e726bf32..81809e3fbeb 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -29,6 +29,65 @@ set_lock_usercode: selector: text: +set_lock_configuration: + target: + entity: + domain: lock + integration: zwave_js + fields: + operation_type: + required: true + example: timed + selector: + select: + options: + - constant + - timed + lock_timeout: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + outside_handles_can_open_door_configuration: + required: false + example: [true, true, true, false] + selector: + object: + inside_handles_can_open_door_configuration: + required: false + example: [true, true, true, false] + selector: + object: + auto_relock_time: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + hold_and_release_time: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + twist_assist: + required: false + example: true + selector: + boolean: + block_to_block: + required: false + example: true + selector: + boolean: + set_config_parameter: target: entity: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 71c6b93e2bd..19a47450080 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -385,6 +385,44 @@ "description": "The Notification Event number as defined in the Z-Wave specs." } } + }, + "set_lock_configuration": { + "name": "Set lock configuration", + "description": "Sets the configuration for a lock.", + "fields": { + "operation_type": { + "name": "Operation Type", + "description": "The operation type of the lock." + }, + "lock_timeout": { + "name": "Lock timeout", + "description": "Seconds until lock mode times out. Should only be used if operation type is `timed`." + }, + "outside_handles_can_open_door_configuration": { + "name": "Outside handles can open door configuration", + "description": "A list of four booleans which indicate which outside handles can open the door." + }, + "inside_handles_can_open_door_configuration": { + "name": "Inside handles can open door configuration", + "description": "A list of four booleans which indicate which inside handles can open the door." + }, + "auto_relock_time": { + "name": "Auto relock time", + "description": "Duration in seconds until lock returns to secure state. Only enforced when operation type is `constant`." + }, + "hold_and_release_time": { + "name": "Hold and release time", + "description": "Duration in seconds the latch stays retracted." + }, + "twist_assist": { + "name": "Twist assist", + "description": "Enable Twist Assist." + }, + "block_to_block": { + "name": "Block to block", + "description": "Enable block-to-block functionality." + } + } } } } diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 5a5711d9dad..2213e9cf069 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -15,10 +15,15 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_UNLOCK, ) -from homeassistant.components.zwave_js.const import DOMAIN as ZWAVE_JS_DOMAIN +from homeassistant.components.zwave_js.const import ( + ATTR_LOCK_TIMEOUT, + ATTR_OPERATION_TYPE, + DOMAIN as ZWAVE_JS_DOMAIN, +) from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.components.zwave_js.lock import ( SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) from homeassistant.const import ( @@ -35,7 +40,11 @@ from .common import SCHLAGE_BE469_LOCK_ENTITY, replace_value_of_zwave_value async def test_door_lock( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a lock entity with door lock command class.""" node = lock_schlage_be469 @@ -158,6 +167,96 @@ async def test_door_lock( client.async_send_command.reset_mock() + # Test set configuration + client.async_send_command.return_value = { + "response": {"status": 1, "remainingDuration": "default"} + } + caplog.clear() + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_SET_LOCK_CONFIGURATION, + { + ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, + ATTR_OPERATION_TYPE: "timed", + ATTR_LOCK_TIMEOUT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == 20 + assert args["endpoint"] == 0 + assert args["args"] == [ + { + "insideHandlesCanOpenDoorConfiguration": [True, True, True, True], + "operationType": 2, + "outsideHandlesCanOpenDoorConfiguration": [True, True, True, True], + } + ] + assert args["commandClass"] == 98 + assert args["methodName"] == "setConfiguration" + assert "Result status" in caplog.text + assert "remaining duration" in caplog.text + assert "setting lock configuration" in caplog.text + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() + caplog.clear() + + # Put node to sleep and validate that we don't wait for a return or log anything + event = Event( + "sleep", + { + "source": "node", + "event": "sleep", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_SET_LOCK_CONFIGURATION, + { + ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, + ATTR_OPERATION_TYPE: "timed", + ATTR_LOCK_TIMEOUT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == 20 + assert args["endpoint"] == 0 + assert args["args"] == [ + { + "insideHandlesCanOpenDoorConfiguration": [True, True, True, True], + "operationType": 2, + "outsideHandlesCanOpenDoorConfiguration": [True, True, True, True], + } + ] + assert args["commandClass"] == 98 + assert args["methodName"] == "setConfiguration" + assert "Result status" not in caplog.text + assert "remaining duration" not in caplog.text + assert "setting lock configuration" not in caplog.text + + # Mark node as alive + event = Event( + "alive", + { + "source": "node", + "event": "alive", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") # Test set usercode service error handling with pytest.raises(HomeAssistantError): From 5b37096b5f9579c55162a205cfaddb8ee897e31f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Nov 2023 19:09:49 +0100 Subject: [PATCH 517/982] Refactor config.async_log_exception (#104034) * Refactor config.async_log_exception * Improve test coverage * Make functions public --- homeassistant/bootstrap.py | 2 +- .../components/device_tracker/legacy.py | 4 +- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/template/config.py | 4 +- homeassistant/config.py | 73 +++++++++++------- homeassistant/helpers/check_config.py | 18 +++-- tests/helpers/test_check_config.py | 37 ++++++++-- tests/snapshots/test_config.ambr | 3 + tests/test_config.py | 74 +++++++++++++++++++ 9 files changed, 173 insertions(+), 44 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index b9bb638e052..288af779fba 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -292,7 +292,7 @@ async def async_from_config_dict( try: await conf_util.async_process_ha_core_config(hass, core_config) except vol.Invalid as config_err: - conf_util.async_log_exception(config_err, "homeassistant", core_config, hass) + conf_util.async_log_schema_error(config_err, "homeassistant", core_config, hass) return None except HomeAssistantError: _LOGGER.error( diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 7c12a2d8777..b893654e8cd 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import util from homeassistant.backports.functools import cached_property from homeassistant.components import zone -from homeassistant.config import async_log_exception, load_yaml_config_file +from homeassistant.config import async_log_schema_error, load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, @@ -1006,7 +1006,7 @@ async def async_load_config( device = dev_schema(device) device["dev_id"] = cv.slugify(dev_id) except vol.Invalid as exp: - async_log_exception(exp, dev_id, devices, hass) + async_log_schema_error(exp, dev_id, devices, hass) else: result.append(Device(hass, **device)) return result diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index effff9fdf12..931615bf0ac 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -248,7 +248,7 @@ async def async_check_config_schema( except vol.Invalid as ex: integration = await async_get_integration(hass, DOMAIN) # pylint: disable-next=protected-access - message, _ = conf_util._format_config_error( + message = conf_util.format_schema_error( ex, domain, config, integration.documentation ) raise ServiceValidationError( diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 3329f185f08..d1198b46577 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -10,7 +10,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN -from homeassistant.config import async_log_exception, config_without_domain +from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import async_validate_trigger_config @@ -80,7 +80,7 @@ async def async_validate_config(hass, config): hass, cfg[CONF_TRIGGER] ) except vol.Invalid as err: - async_log_exception(err, DOMAIN, cfg, hass) + async_log_schema_error(err, DOMAIN, cfg, hass) continue legacy_warn_printed = False diff --git a/homeassistant/config.py b/homeassistant/config.py index 1a4342c2e87..272ad59d1f4 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -490,21 +490,37 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: @callback -def async_log_exception( - ex: Exception, +def async_log_schema_error( + ex: vol.Invalid, domain: str, config: dict, hass: HomeAssistant, link: str | None = None, ) -> None: - """Log an error for configuration validation. - - This method must be run in the event loop. - """ + """Log a schema validation error.""" if hass is not None: async_notify_setup_error(hass, domain, link) - message, is_friendly = _format_config_error(ex, domain, config, link) - _LOGGER.error(message, exc_info=not is_friendly and ex) + message = format_schema_error(ex, domain, config, link) + _LOGGER.error(message) + + +@callback +def async_log_config_validator_error( + ex: vol.Invalid | HomeAssistantError, + domain: str, + config: dict, + hass: HomeAssistant, + link: str | None = None, +) -> None: + """Log an error from a custom config validator.""" + if isinstance(ex, vol.Invalid): + async_log_schema_error(ex, domain, config, hass, link) + return + + if hass is not None: + async_notify_setup_error(hass, domain, link) + message = format_homeassistant_error(ex, domain, config, link) + _LOGGER.error(message, exc_info=ex) def _get_annotation(item: Any) -> tuple[str, int | str] | None: @@ -655,25 +671,24 @@ def humanize_error( @callback -def _format_config_error( - ex: Exception, domain: str, config: dict, link: str | None = None -) -> tuple[str, bool]: - """Generate log exception for configuration validation. +def format_homeassistant_error( + ex: HomeAssistantError, domain: str, config: dict, link: str | None = None +) -> str: + """Format HomeAssistantError thrown by a custom config validator.""" + message = f"Invalid config for [{domain}]: {str(ex) or repr(ex)}" - This method must be run in the event loop. - """ - is_friendly = False + if domain != CONF_CORE and link: + message += f" Please check the docs at {link}." - if isinstance(ex, vol.Invalid): - message = humanize_error(ex, domain, config, link) - is_friendly = True - else: - message = f"Invalid config for [{domain}]: {str(ex) or repr(ex)}" + return message - if domain != CONF_CORE and link: - message += f" Please check the docs at {link}." - return message, is_friendly +@callback +def format_schema_error( + ex: vol.Invalid, domain: str, config: dict, link: str | None = None +) -> str: + """Format configuration validation error.""" + return humanize_error(ex, domain, config, link) async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> None: @@ -995,7 +1010,9 @@ async def async_process_component_config( # noqa: C901 await config_validator.async_validate_config(hass, config) ) except (vol.Invalid, HomeAssistantError) as ex: - async_log_exception(ex, domain, config, hass, integration.documentation) + async_log_config_validator_error( + ex, domain, config, hass, integration.documentation + ) return None except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown error calling %s config validator", domain) @@ -1006,7 +1023,7 @@ async def async_process_component_config( # noqa: C901 try: return component.CONFIG_SCHEMA(config) # type: ignore[no-any-return] except vol.Invalid as ex: - async_log_exception(ex, domain, config, hass, integration.documentation) + async_log_schema_error(ex, domain, config, hass, integration.documentation) return None except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown error calling %s CONFIG_SCHEMA", domain) @@ -1025,7 +1042,9 @@ async def async_process_component_config( # noqa: C901 try: p_validated = component_platform_schema(p_config) except vol.Invalid as ex: - async_log_exception(ex, domain, p_config, hass, integration.documentation) + async_log_schema_error( + ex, domain, p_config, hass, integration.documentation + ) continue except Exception: # pylint: disable=broad-except _LOGGER.exception( @@ -1062,7 +1081,7 @@ async def async_process_component_config( # noqa: C901 try: p_validated = platform.PLATFORM_SCHEMA(p_config) except vol.Invalid as ex: - async_log_exception( + async_log_schema_error( ex, f"{domain}.{p_name}", p_config, diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 441381f9994..e177d30d94d 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -15,9 +15,10 @@ from homeassistant.config import ( # type: ignore[attr-defined] CONF_PACKAGES, CORE_CONFIG_SCHEMA, YAML_CONFIG_FILE, - _format_config_error, config_per_platform, extract_domain_configs, + format_homeassistant_error, + format_schema_error, load_yaml_config_file, merge_packages_config, ) @@ -94,15 +95,20 @@ async def async_check_ha_config_file( # noqa: C901 def _pack_error( package: str, component: str, config: ConfigType, message: str ) -> None: - """Handle errors from packages: _log_pkg_error.""" + """Handle errors from packages.""" message = f"Package {package} setup failed. Component {component} {message}" domain = f"homeassistant.packages.{package}.{component}" pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_warning(message, domain, pack_config) - def _comp_error(ex: Exception, domain: str, component_config: ConfigType) -> None: - """Handle errors from components: async_log_exception.""" - message = _format_config_error(ex, domain, component_config)[0] + def _comp_error( + ex: vol.Invalid | HomeAssistantError, domain: str, component_config: ConfigType + ) -> None: + """Handle errors from components.""" + if isinstance(ex, vol.Invalid): + message = format_schema_error(ex, domain, component_config) + else: + message = format_homeassistant_error(ex, domain, component_config) if domain in frontend_dependencies: result.add_error(message, domain, component_config) else: @@ -149,7 +155,7 @@ async def async_check_ha_config_file( # noqa: C901 result[CONF_CORE] = core_config except vol.Invalid as err: result.add_error( - _format_config_error(err, CONF_CORE, core_config)[0], CONF_CORE, core_config + format_schema_error(err, CONF_CORE, core_config), CONF_CORE, core_config ) core_config = {} diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 6e7245603b6..8a49f23cf46 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.config import YAML_CONFIG_FILE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.check_config import ( CheckConfigError, HomeAssistantConfig, @@ -440,12 +441,38 @@ action: assert "input_datetime" in res -async def test_config_platform_raise(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "errors", "warnings", "message", "config"), + [ + ( + Exception("Broken"), + 1, + 0, + "Unexpected error calling config validator: Broken", + {"value": 1}, + ), + ( + HomeAssistantError("Broken"), + 0, + 1, + "Invalid config for [bla]: Broken", + {"bla": {"value": 1}}, + ), + ], +) +async def test_config_platform_raise( + hass: HomeAssistant, + exception: Exception, + errors: int, + warnings: int, + message: str, + config: dict, +) -> None: """Test bad config validation platform.""" mock_platform( hass, "bla.config", - Mock(async_validate_config=Mock(side_effect=Exception("Broken"))), + Mock(async_validate_config=Mock(side_effect=exception)), ) files = { YAML_CONFIG_FILE: BASE_CONFIG @@ -457,11 +484,11 @@ bla: with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) error = CheckConfigError( - "Unexpected error calling config validator: Broken", + message, "bla", - {"value": 1}, + config, ) - _assert_warnings_errors(res, [], [error]) + _assert_warnings_errors(res, [error] * warnings, [error] * errors) async def test_removed_yaml_support(hass: HomeAssistant) -> None: diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 9df27e02f90..19ff83f0d08 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -305,6 +305,9 @@ Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. ''', + "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 52: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/custom_validator_ok_2.", + 'Invalid config for [custom_validator_bad_1]: broken Please check the docs at https://www.home-assistant.io/integrations/custom_validator_bad_1.', + 'Unknown error calling custom_validator_bad_2 config validator', ]) # --- # name: test_package_merge_error[packages] diff --git a/tests/test_config.py b/tests/test_config.py index 2abb2f08d60..d39a1d4e907 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -295,6 +295,75 @@ async def mock_custom_validator_integrations(hass: HomeAssistant) -> list[Integr ) +@pytest.fixture +async def mock_custom_validator_integrations_with_docs( + hass: HomeAssistant, +) -> list[Integration]: + """Mock integrations with custom validator.""" + integrations = [] + + for domain in ("custom_validator_ok_1", "custom_validator_ok_2"): + + def gen_async_validate_config(domain): + schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Optional("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + + async def async_validate_config( + hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return schema(config) + + return async_validate_config + + integrations.append( + mock_integration( + hass, + MockModule( + domain, + partial_manifest={ + "documentation": f"https://www.home-assistant.io/integrations/{domain}" + }, + ), + ) + ) + mock_platform( + hass, + f"{domain}.config", + Mock(async_validate_config=gen_async_validate_config(domain)), + ) + + for domain, exception in [ + ("custom_validator_bad_1", HomeAssistantError("broken")), + ("custom_validator_bad_2", ValueError("broken")), + ]: + integrations.append( + mock_integration( + hass, + MockModule( + domain, + partial_manifest={ + "documentation": f"https://www.home-assistant.io/integrations/{domain}" + }, + ), + ) + ) + mock_platform( + hass, + f"{domain}.config", + Mock(async_validate_config=AsyncMock(side_effect=exception)), + ) + + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" assert not os.path.isfile(YAML_PATH) @@ -1683,6 +1752,7 @@ async def test_component_config_validation_error_with_docs( mock_iot_domain_integration_with_docs: Integration, mock_non_adr_0007_integration_with_docs: None, mock_adr_0007_integrations_with_docs: list[Integration], + mock_custom_validator_integrations_with_docs: list[Integration], snapshot: SnapshotAssertion, ) -> None: """Test schema error in component.""" @@ -1700,6 +1770,10 @@ async def test_component_config_validation_error_with_docs( "adr_0007_3", "adr_0007_4", "adr_0007_5", + "custom_validator_ok_1", + "custom_validator_ok_2", + "custom_validator_bad_1", + "custom_validator_bad_2", ]: integration = await async_get_integration(hass, domain) await config_util.async_process_component_config( From c92945ecd648a02c3a8b486d55a2d506e1d1cf50 Mon Sep 17 00:00:00 2001 From: deosrc Date: Wed, 15 Nov 2023 20:28:16 +0000 Subject: [PATCH 518/982] Fix netatmo authentication when using cloud authentication credentials (#104021) * Fix netatmo authentication loop * Update unit tests * Move logic to determine api scopes * Add unit tests for new method * Use pyatmo scope list (#1) * Exclude scopes not working with cloud * Fix linting error --------- Co-authored-by: Tobias Sauerwein --- homeassistant/components/netatmo/__init__.py | 19 +++++----------- homeassistant/components/netatmo/api.py | 18 +++++++++++++++ .../components/netatmo/config_flow.py | 10 ++------- homeassistant/components/netatmo/const.py | 7 ++++++ tests/components/netatmo/test_api.py | 22 +++++++++++++++++++ 5 files changed, 54 insertions(+), 22 deletions(-) create mode 100644 tests/components/netatmo/test_api.py diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index ddd2fc61ed7..4535805915b 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -8,7 +8,6 @@ from typing import Any import aiohttp import pyatmo -from pyatmo.const import ALL_SCOPES as NETATMO_SCOPES import voluptuous as vol from homeassistant.components import cloud @@ -143,7 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await session.async_ensure_token_valid() except aiohttp.ClientResponseError as ex: - _LOGGER.debug("API error: %s (%s)", ex.status, ex.message) + _LOGGER.warning("API error: %s (%s)", ex.status, ex.message) if ex.status in ( HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, @@ -152,19 +151,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex raise ConfigEntryNotReady from ex - if entry.data["auth_implementation"] == cloud.DOMAIN: - required_scopes = { - scope - for scope in NETATMO_SCOPES - if scope not in ("access_doorbell", "read_doorbell") - } - else: - required_scopes = set(NETATMO_SCOPES) - - if not (set(session.token["scope"]) & required_scopes): - _LOGGER.debug( + required_scopes = api.get_api_scopes(entry.data["auth_implementation"]) + if not (set(session.token["scope"]) & set(required_scopes)): + _LOGGER.warning( "Session is missing scopes: %s", - required_scopes - set(session.token["scope"]), + set(required_scopes) - set(session.token["scope"]), ) raise ConfigEntryAuthFailed("Token scope not valid, trigger renewal") diff --git a/homeassistant/components/netatmo/api.py b/homeassistant/components/netatmo/api.py index 0b36745338e..7605689b3f5 100644 --- a/homeassistant/components/netatmo/api.py +++ b/homeassistant/components/netatmo/api.py @@ -1,11 +1,29 @@ """API for Netatmo bound to HASS OAuth.""" +from collections.abc import Iterable from typing import cast from aiohttp import ClientSession import pyatmo +from homeassistant.components import cloud from homeassistant.helpers import config_entry_oauth2_flow +from .const import API_SCOPES_EXCLUDED_FROM_CLOUD + + +def get_api_scopes(auth_implementation: str) -> Iterable[str]: + """Return the Netatmo API scopes based on the auth implementation.""" + + if auth_implementation == cloud.DOMAIN: + return set( + { + scope + for scope in pyatmo.const.ALL_SCOPES + if scope not in API_SCOPES_EXCLUDED_FROM_CLOUD + } + ) + return sorted(pyatmo.const.ALL_SCOPES) + class AsyncConfigEntryNetatmoAuth(pyatmo.AbstractAsyncAuth): """Provide Netatmo authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index b4e6d838537..bae81a7762f 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -6,7 +6,6 @@ import logging from typing import Any import uuid -from pyatmo.const import ALL_SCOPES import voluptuous as vol from homeassistant import config_entries @@ -15,6 +14,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from .api import get_api_scopes from .const import ( CONF_AREA_NAME, CONF_LAT_NE, @@ -53,13 +53,7 @@ class NetatmoFlowHandler( @property def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - exclude = [] - if self.flow_impl.name == "Home Assistant Cloud": - exclude = ["access_doorbell", "read_doorbell"] - - scopes = [scope for scope in ALL_SCOPES if scope not in exclude] - scopes.sort() - + scopes = get_api_scopes(self.flow_impl.domain) return {"scope": " ".join(scopes)} async def async_step_user(self, user_input: dict | None = None) -> FlowResult: diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 9e7ac33c8b6..8a281d4d4a2 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -30,6 +30,13 @@ HOME_DATA = "netatmo_home_data" DATA_HANDLER = "netatmo_data_handler" SIGNAL_NAME = "signal_name" +API_SCOPES_EXCLUDED_FROM_CLOUD = [ + "access_doorbell", + "read_doorbell", + "read_mhs1", + "write_mhs1", +] + NETATMO_CREATE_BATTERY = "netatmo_create_battery" NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" diff --git a/tests/components/netatmo/test_api.py b/tests/components/netatmo/test_api.py new file mode 100644 index 00000000000..e2d495555c6 --- /dev/null +++ b/tests/components/netatmo/test_api.py @@ -0,0 +1,22 @@ +"""The tests for the Netatmo api.""" + +from pyatmo.const import ALL_SCOPES + +from homeassistant.components import cloud +from homeassistant.components.netatmo import api +from homeassistant.components.netatmo.const import API_SCOPES_EXCLUDED_FROM_CLOUD + + +async def test_get_api_scopes_cloud() -> None: + """Test method to get API scopes when using cloud auth implementation.""" + result = api.get_api_scopes(cloud.DOMAIN) + + for scope in API_SCOPES_EXCLUDED_FROM_CLOUD: + assert scope not in result + + +async def test_get_api_scopes_other() -> None: + """Test method to get API scopes when using cloud auth implementation.""" + result = api.get_api_scopes("netatmo_239846i2f0j2") + + assert sorted(ALL_SCOPES) == result From 45f1d50f03e9ed64b8e88dbb691762ec38dc4b66 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 15 Nov 2023 16:20:15 -0600 Subject: [PATCH 519/982] Add HassGetWeather intent (#102613) * Add HassGetWeather intent * Use async_match_states * Extend test coverage * Use get_entity * Update homeassistant/components/weather/intent.py * Fix state --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/weather/intent.py | 85 ++++++++++++++++ tests/components/weather/test_intent.py | 108 +++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 homeassistant/components/weather/intent.py create mode 100644 tests/components/weather/test_intent.py diff --git a/homeassistant/components/weather/intent.py b/homeassistant/components/weather/intent.py new file mode 100644 index 00000000000..4fd22ceb0a9 --- /dev/null +++ b/homeassistant/components/weather/intent.py @@ -0,0 +1,85 @@ +"""Intents for the weather integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent + +from . import DOMAIN, WeatherEntity + +INTENT_GET_WEATHER = "HassGetWeather" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the weather intents.""" + intent.async_register(hass, GetWeatherIntent()) + + +class GetWeatherIntent(intent.IntentHandler): + """Handle GetWeather intents.""" + + intent_type = INTENT_GET_WEATHER + slot_schema = {vol.Optional("name"): cv.string} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + weather: WeatherEntity | None = None + weather_state: State | None = None + component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] + entities = list(component.entities) + + if "name" in slots: + # Named weather entity + weather_name = slots["name"]["value"] + + # Find matching weather entity + matching_states = intent.async_match_states( + hass, name=weather_name, domains=[DOMAIN] + ) + for maybe_weather_state in matching_states: + weather = component.get_entity(maybe_weather_state.entity_id) + if weather is not None: + weather_state = maybe_weather_state + break + + if weather is None: + raise intent.IntentHandleError( + f"No weather entity named {weather_name}" + ) + elif entities: + # First weather entity + weather = entities[0] + weather_name = weather.name + weather_state = hass.states.get(weather.entity_id) + + if weather is None: + raise intent.IntentHandleError("No weather entity") + + if weather_state is None: + raise intent.IntentHandleError(f"No state for weather: {weather.name}") + + assert weather is not None + assert weather_state is not None + + # Create response + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=weather_name, + id=weather.entity_id, + ) + ] + ) + + response.async_set_states(matched_states=[weather_state]) + + return response diff --git a/tests/components/weather/test_intent.py b/tests/components/weather/test_intent.py new file mode 100644 index 00000000000..1a171da7fae --- /dev/null +++ b/tests/components/weather/test_intent.py @@ -0,0 +1,108 @@ +"""Test weather intents.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.weather import ( + DOMAIN, + WeatherEntity, + intent as weather_intent, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + + +async def test_get_weather(hass: HomeAssistant) -> None: + """Test get weather for first entity and by name.""" + assert await async_setup_component(hass, "weather", {"weather": {}}) + + entity1 = WeatherEntity() + entity1._attr_name = "Weather 1" + entity1.entity_id = "weather.test_1" + + entity2 = WeatherEntity() + entity2._attr_name = "Weather 2" + entity2.entity_id = "weather.test_2" + + await hass.data[DOMAIN].async_add_entities([entity1, entity2]) + + await weather_intent.async_setup_intents(hass) + + # First entity will be chosen + response = await intent.async_handle( + hass, "test", weather_intent.INTENT_GET_WEATHER, {} + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + state = response.matched_states[0] + assert state.entity_id == entity1.entity_id + + # Named entity will be chosen + response = await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": "Weather 2"}}, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + state = response.matched_states[0] + assert state.entity_id == entity2.entity_id + + +async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: + """Test get weather with the wrong name.""" + assert await async_setup_component(hass, "weather", {"weather": {}}) + + entity1 = WeatherEntity() + entity1._attr_name = "Weather 1" + entity1.entity_id = "weather.test_1" + + await hass.data[DOMAIN].async_add_entities([entity1]) + + await weather_intent.async_setup_intents(hass) + + # Incorrect name + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": "not the right name"}}, + ) + + +async def test_get_weather_no_entities(hass: HomeAssistant) -> None: + """Test get weather with no weather entities.""" + assert await async_setup_component(hass, "weather", {"weather": {}}) + await weather_intent.async_setup_intents(hass) + + # No weather entities + with pytest.raises(intent.IntentHandleError): + await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) + + +async def test_get_weather_no_state(hass: HomeAssistant) -> None: + """Test get weather when state is not returned.""" + assert await async_setup_component(hass, "weather", {"weather": {}}) + + entity1 = WeatherEntity() + entity1._attr_name = "Weather 1" + entity1.entity_id = "weather.test_1" + + await hass.data[DOMAIN].async_add_entities([entity1]) + + await weather_intent.async_setup_intents(hass) + + # Success with state + response = await intent.async_handle( + hass, "test", weather_intent.INTENT_GET_WEATHER, {} + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + + # Failure without state + with patch("homeassistant.core.StateMachine.get", return_value=None), pytest.raises( + intent.IntentHandleError + ): + await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) From 0899be6d4b8ebe1e858f1d25b46d2c84668d2fe0 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 15 Nov 2023 18:59:37 -0500 Subject: [PATCH 520/982] Migrate Hydrawise to an async client library (#103636) * Migrate Hydrawise to an async client library * Changes requested during review * Additional changes requested during review --- .../components/hydrawise/__init__.py | 2 +- .../components/hydrawise/binary_sensor.py | 32 ++-- .../components/hydrawise/config_flow.py | 17 +- .../components/hydrawise/coordinator.py | 15 +- homeassistant/components/hydrawise/entity.py | 16 +- homeassistant/components/hydrawise/sensor.py | 32 ++-- homeassistant/components/hydrawise/switch.py | 57 +++---- tests/components/hydrawise/conftest.py | 145 +++++++++++------- .../hydrawise/test_binary_sensor.py | 8 +- .../components/hydrawise/test_config_flow.py | 84 +++++----- tests/components/hydrawise/test_init.py | 26 +--- tests/components/hydrawise/test_sensor.py | 18 +++ tests/components/hydrawise/test_switch.py | 34 ++-- 13 files changed, 254 insertions(+), 232 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ddff1954eb3..9f44d47ecf6 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -51,7 +51,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" access_token = config_entry.data[CONF_API_KEY] - hydrawise = legacy.LegacyHydrawise(access_token, load_on_init=False) + hydrawise = legacy.LegacyHydrawiseAsync(access_token) coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 1953e413672..65355a1829f 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Hydrawise sprinkler binary sensors.""" from __future__ import annotations -from pydrawise.legacy import LegacyHydrawise +from pydrawise.schema import Zone import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -69,26 +69,16 @@ async def async_setup_entry( coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - hydrawise: LegacyHydrawise = coordinator.api - - entities = [ - HydrawiseBinarySensor( - data=hydrawise.current_controller, - coordinator=coordinator, - description=BINARY_SENSOR_STATUS, - device_id_key="controller_id", + entities = [] + for controller in coordinator.data.controllers: + entities.append( + HydrawiseBinarySensor(coordinator, BINARY_SENSOR_STATUS, controller) ) - ] - - # create a sensor for each zone - for zone in hydrawise.relays: - for description in BINARY_SENSOR_TYPES: - entities.append( - HydrawiseBinarySensor( - data=zone, coordinator=coordinator, description=description + for zone in controller.zones: + for description in BINARY_SENSOR_TYPES: + entities.append( + HydrawiseBinarySensor(coordinator, description, controller, zone) ) - ) - async_add_entities(entities) @@ -100,5 +90,5 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): if self.entity_description.key == "status": self._attr_is_on = self.coordinator.last_update_success elif self.entity_description.key == "is_watering": - relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] - self._attr_is_on = relay_data["timestr"] == "Now" + zone: Zone = self.zone + self._attr_is_on = zone.scheduled_runs.current_run is not None diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index c4b37fb4a06..72df86606d7 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -5,8 +5,8 @@ from __future__ import annotations from collections.abc import Callable from typing import Any +from aiohttp import ClientError from pydrawise import legacy -from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant import config_entries @@ -27,20 +27,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, api_key: str, *, on_failure: Callable[[str], FlowResult] ) -> FlowResult: """Create the config entry.""" + api = legacy.LegacyHydrawiseAsync(api_key) try: - api = await self.hass.async_add_executor_job( - legacy.LegacyHydrawise, api_key - ) - except ConnectTimeout: + # Skip fetching zones to save on metered API calls. + user = await api.get_user(fetch_zones=False) + except TimeoutError: return on_failure("timeout_connect") - except HTTPError as ex: + except ClientError as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) return on_failure("cannot_connect") - if not api.status: - return on_failure("unknown") - - await self.async_set_unique_id(f"hydrawise-{api.customer_id}") + await self.async_set_unique_id(f"hydrawise-{user.customer_id}") self._abort_if_unique_id_configured() return self.async_create_entry(title="Hydrawise", data={CONF_API_KEY: api_key}) diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 007b15d2403..412108f859f 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -4,26 +4,25 @@ from __future__ import annotations from datetime import timedelta -from pydrawise.legacy import LegacyHydrawise +from pydrawise import HydrawiseBase +from pydrawise.schema import User from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER -class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[None]): +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[User]): """The Hydrawise Data Update Coordinator.""" def __init__( - self, hass: HomeAssistant, api: LegacyHydrawise, scan_interval: timedelta + self, hass: HomeAssistant, api: HydrawiseBase, scan_interval: timedelta ) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) self.api = api - async def _async_update_data(self) -> None: + async def _async_update_data(self) -> User: """Fetch the latest data from Hydrawise.""" - result = await self.hass.async_add_executor_job(self.api.update_controller_info) - if not result: - raise UpdateFailed("Failed to refresh Hydrawise data") + return await self.api.get_user() diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 38fde322673..c707690ce95 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -1,7 +1,7 @@ """Base classes for Hydrawise entities.""" from __future__ import annotations -from typing import Any +from pydrawise.schema import Controller, Zone from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -20,23 +20,25 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): def __init__( self, - *, - data: dict[str, Any], coordinator: HydrawiseDataUpdateCoordinator, description: EntityDescription, - device_id_key: str = "relay_id", + controller: Controller, + zone: Zone | None = None, ) -> None: """Initialize the Hydrawise entity.""" super().__init__(coordinator=coordinator) - self.data = data self.entity_description = description - self._device_id = str(data.get(device_id_key)) + self.controller = controller + self.zone = zone + self._device_id = str(controller.id if zone is None else zone.id) self._attr_unique_id = f"{self._device_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, - name=data["name"], + name=controller.name if zone is None else zone.name, manufacturer=MANUFACTURER, ) + if zone is not None: + self._attr_device_info["via_device"] = (DOMAIN, str(controller.id)) self._update_attrs() def _update_attrs(self) -> None: diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 369e952c1be..79a318f778f 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,6 +1,9 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations +from datetime import datetime + +from pydrawise.schema import Zone import voluptuous as vol from homeassistant.components.sensor import ( @@ -71,27 +74,30 @@ async def async_setup_entry( coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - entities = [ - HydrawiseSensor(data=zone, coordinator=coordinator, description=description) - for zone in coordinator.api.relays + async_add_entities( + HydrawiseSensor(coordinator, description, controller, zone) + for controller in coordinator.data.controllers + for zone in controller.zones for description in SENSOR_TYPES - ] - async_add_entities(entities) + ) class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" + zone: Zone + def _update_attrs(self) -> None: """Update state attributes.""" - relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] if self.entity_description.key == "watering_time": - if relay_data["timestr"] == "Now": - self._attr_native_value = int(relay_data["run"] / 60) + if (current_run := self.zone.scheduled_runs.current_run) is not None: + self._attr_native_value = int( + current_run.remaining_time.total_seconds() / 60 + ) else: self._attr_native_value = 0 - else: # _sensor_type == 'next_cycle' - next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) - self._attr_native_value = dt_util.utc_from_timestamp( - dt_util.as_timestamp(dt_util.now()) + next_cycle - ) + elif self.entity_description.key == "next_cycle": + if (next_run := self.zone.scheduled_runs.next_run) is not None: + self._attr_native_value = dt_util.as_utc(next_run.start_time) + else: + self._attr_native_value = datetime.max.replace(tzinfo=dt_util.UTC) diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 2aa4ecc085b..5dd79d4a13e 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -1,8 +1,10 @@ """Support for Hydrawise cloud switches.""" from __future__ import annotations +from datetime import timedelta from typing import Any +from pydrawise.schema import Zone import voluptuous as vol from homeassistant.components.switch import ( @@ -17,6 +19,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from .const import ( ALLOWED_WATERING_TIME, @@ -76,62 +79,44 @@ async def async_setup_entry( coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - default_watering_timer = DEFAULT_WATERING_TIME - - entities = [ - HydrawiseSwitch( - data=zone, - coordinator=coordinator, - description=description, - default_watering_timer=default_watering_timer, - ) - for zone in coordinator.api.relays + async_add_entities( + HydrawiseSwitch(coordinator, description, controller, zone) + for controller in coordinator.data.controllers + for zone in controller.zones for description in SWITCH_TYPES - ] - - async_add_entities(entities) + ) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """A switch implementation for Hydrawise device.""" - def __init__( - self, - *, - data: dict[str, Any], - coordinator: HydrawiseDataUpdateCoordinator, - description: SwitchEntityDescription, - default_watering_timer: int, - ) -> None: - """Initialize a switch for Hydrawise device.""" - super().__init__(data=data, coordinator=coordinator, description=description) - self._default_watering_timer = default_watering_timer + zone: Zone - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - zone_number = self.data["relay"] if self.entity_description.key == "manual_watering": - self.coordinator.api.run_zone(self._default_watering_timer, zone_number) + await self.coordinator.api.start_zone( + self.zone, custom_run_duration=DEFAULT_WATERING_TIME + ) elif self.entity_description.key == "auto_watering": - self.coordinator.api.suspend_zone(0, zone_number) + await self.coordinator.api.resume_zone(self.zone) self._attr_is_on = True self.async_write_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - zone_number = self.data["relay"] if self.entity_description.key == "manual_watering": - self.coordinator.api.run_zone(0, zone_number) + await self.coordinator.api.stop_zone(self.zone) elif self.entity_description.key == "auto_watering": - self.coordinator.api.suspend_zone(365, zone_number) + await self.coordinator.api.suspend_zone( + self.zone, dt_util.now() + timedelta(days=365) + ) self._attr_is_on = False self.async_write_ha_state() def _update_attrs(self) -> None: """Update state attributes.""" - zone_number = self.data["relay"] - timestr = self.coordinator.api.relays_by_zone_number[zone_number]["timestr"] if self.entity_description.key == "manual_watering": - self._attr_is_on = timestr == "Now" + self._attr_is_on = self.zone.scheduled_runs.current_run is not None elif self.entity_description.key == "auto_watering": - self._attr_is_on = timestr not in {"", "Now"} + self._attr_is_on = self.zone.status.suspended_until is None diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 4a6c8372e57..1f892785812 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -1,14 +1,23 @@ """Common fixtures for the Hydrawise tests.""" -from collections.abc import Generator -from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Awaitable, Callable, Generator +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch +from pydrawise.schema import ( + Controller, + ControllerHardware, + ScheduledZoneRun, + ScheduledZoneRuns, + User, + Zone, +) import pytest from homeassistant.components.hydrawise.const import DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -24,59 +33,71 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture def mock_pydrawise( - mock_controller: dict[str, Any], - mock_zones: list[dict[str, Any]], -) -> Generator[Mock, None, None]: - """Mock LegacyHydrawise.""" - with patch("pydrawise.legacy.LegacyHydrawise", autospec=True) as mock_pydrawise: - mock_pydrawise.return_value.controller_info = {"controllers": [mock_controller]} - mock_pydrawise.return_value.current_controller = mock_controller - mock_pydrawise.return_value.controller_status = {"relays": mock_zones} - mock_pydrawise.return_value.relays = mock_zones - mock_pydrawise.return_value.relays_by_zone_number = { - r["relay"]: r for r in mock_zones - } + user: User, + controller: Controller, + zones: list[Zone], +) -> Generator[AsyncMock, None, None]: + """Mock LegacyHydrawiseAsync.""" + with patch( + "pydrawise.legacy.LegacyHydrawiseAsync", autospec=True + ) as mock_pydrawise: + user.controllers = [controller] + controller.zones = zones + mock_pydrawise.return_value.get_user.return_value = user yield mock_pydrawise.return_value @pytest.fixture -def mock_controller() -> dict[str, Any]: - """Mock Hydrawise controller.""" - return { - "name": "Home Controller", - "last_contact": 1693292420, - "serial_number": "0310b36090", - "controller_id": 52496, - "status": "Unknown", - } +def user() -> User: + """Hydrawise User fixture.""" + return User(customer_id=12345) @pytest.fixture -def mock_zones() -> list[dict[str, Any]]: - """Mock Hydrawise zones.""" +def controller() -> Controller: + """Hydrawise Controller fixture.""" + return Controller( + id=52496, + name="Home Controller", + hardware=ControllerHardware( + serial_number="0310b36090", + ), + last_contact_time=datetime.fromtimestamp(1693292420), + online=True, + ) + + +@pytest.fixture +def zones() -> list[Zone]: + """Hydrawise zone fixtures.""" return [ - { - "name": "Zone One", - "period": 259200, - "relay": 1, - "relay_id": 5965394, - "run": 1800, - "stop": 1, - "time": 330597, - "timestr": "Sat", - "type": 1, - }, - { - "name": "Zone Two", - "period": 259200, - "relay": 2, - "relay_id": 5965395, - "run": 1788, - "stop": 1, - "time": 1, - "timestr": "Now", - "type": 106, - }, + Zone( + name="Zone One", + number=1, + id=5965394, + scheduled_runs=ScheduledZoneRuns( + summary="", + current_run=None, + next_run=ScheduledZoneRun( + start_time=dt_util.now() + timedelta(seconds=330597), + end_time=dt_util.now() + + timedelta(seconds=330597) + + timedelta(seconds=1800), + normal_duration=timedelta(seconds=1800), + duration=timedelta(seconds=1800), + ), + ), + ), + Zone( + name="Zone Two", + number=2, + id=5965395, + scheduled_runs=ScheduledZoneRuns( + current_run=ScheduledZoneRun( + remaining_time=timedelta(seconds=1788), + ), + ), + ), ] @@ -95,13 +116,25 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def mock_added_config_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_pydrawise: Mock, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]] ) -> MockConfigEntry: """Mock ConfigEntry that's been added to HA.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert DOMAIN in hass.config_entries.async_domains() - return mock_config_entry + return await mock_add_config_entry() + + +@pytest.fixture +async def mock_add_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, +) -> Callable[[], Awaitable[MockConfigEntry]]: + """Callable that creates a mock ConfigEntry that's been added to HA.""" + + async def callback() -> MockConfigEntry: + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry + + return callback diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py index c60f4392f1e..f4702758136 100644 --- a/tests/components/hydrawise/test_binary_sensor.py +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -1,8 +1,9 @@ """Test Hydrawise binary_sensor.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import AsyncMock +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from homeassistant.components.hydrawise.const import SCAN_INTERVAL @@ -33,12 +34,13 @@ async def test_states( async def test_update_data_fails( hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, - mock_pydrawise: Mock, + mock_pydrawise: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that no data from the API sets the correct connectivity.""" # Make the coordinator refresh data. - mock_pydrawise.update_controller_info.return_value = None + mock_pydrawise.get_user.reset_mock(return_value=True) + mock_pydrawise.get_user.side_effect = ClientError freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index c9efbea507e..17c3eda1699 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -1,9 +1,10 @@ """Test the Hydrawise config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock +from aiohttp import ClientError +from pydrawise.schema import User import pytest -from requests.exceptions import ConnectTimeout, HTTPError from homeassistant import config_entries from homeassistant.components.hydrawise.const import DOMAIN @@ -17,9 +18,11 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -@patch("pydrawise.legacy.LegacyHydrawise") async def test_form( - mock_api: MagicMock, hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pydrawise: AsyncMock, + user: User, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -32,19 +35,22 @@ async def test_form( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_key": "abc123"} ) - mock_api.return_value.customer_id = 12345 + mock_pydrawise.get_user.return_value = user await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "Hydrawise" assert result2["data"] == {"api_key": "abc123"} assert len(mock_setup_entry.mock_calls) == 1 + mock_pydrawise.get_user.assert_called_once_with(fetch_zones=False) -@patch("pydrawise.legacy.LegacyHydrawise") -async def test_form_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: +async def test_form_api_error( + hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User +) -> None: """Test we handle API errors.""" - mock_api.side_effect = HTTPError + mock_pydrawise.get_user.side_effect = ClientError("XXX") + init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -55,15 +61,17 @@ async def test_form_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - mock_api.side_effect = None + mock_pydrawise.get_user.reset_mock(side_effect=True) + mock_pydrawise.get_user.return_value = user result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result2["type"] == FlowResultType.CREATE_ENTRY -@patch("pydrawise.legacy.LegacyHydrawise") -async def test_form_connect_timeout(mock_api: MagicMock, hass: HomeAssistant) -> None: +async def test_form_connect_timeout( + hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User +) -> None: """Test we handle API errors.""" - mock_api.side_effect = ConnectTimeout + mock_pydrawise.get_user.side_effect = TimeoutError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -75,15 +83,17 @@ async def test_form_connect_timeout(mock_api: MagicMock, hass: HomeAssistant) -> assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "timeout_connect"} - mock_api.side_effect = None + mock_pydrawise.get_user.reset_mock(side_effect=True) + mock_pydrawise.get_user.return_value = user result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result2["type"] == FlowResultType.CREATE_ENTRY -@patch("pydrawise.legacy.LegacyHydrawise") -async def test_flow_import_success(mock_api: MagicMock, hass: HomeAssistant) -> None: +async def test_flow_import_success( + hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User +) -> None: """Test that we can import a YAML config.""" - mock_api.return_value.status = "All good!" + mock_pydrawise.get_user.return_value = User result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -107,9 +117,11 @@ async def test_flow_import_success(mock_api: MagicMock, hass: HomeAssistant) -> assert issue.translation_key == "deprecated_yaml" -@patch("pydrawise.legacy.LegacyHydrawise", side_effect=HTTPError) -async def test_flow_import_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: +async def test_flow_import_api_error( + hass: HomeAssistant, mock_pydrawise: AsyncMock +) -> None: """Test that we handle API errors on YAML import.""" + mock_pydrawise.get_user.side_effect = ClientError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -129,11 +141,11 @@ async def test_flow_import_api_error(mock_api: MagicMock, hass: HomeAssistant) - assert issue.translation_key == "deprecated_yaml_import_issue" -@patch("pydrawise.legacy.LegacyHydrawise", side_effect=ConnectTimeout) async def test_flow_import_connect_timeout( - mock_api: MagicMock, hass: HomeAssistant + hass: HomeAssistant, mock_pydrawise: AsyncMock ) -> None: """Test that we handle connection timeouts on YAML import.""" + mock_pydrawise.get_user.side_effect = TimeoutError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -153,32 +165,8 @@ async def test_flow_import_connect_timeout( assert issue.translation_key == "deprecated_yaml_import_issue" -@patch("pydrawise.legacy.LegacyHydrawise") -async def test_flow_import_no_status(mock_api: MagicMock, hass: HomeAssistant) -> None: - """Test we handle a lack of API status on YAML import.""" - mock_api.return_value.status = None - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "__api_key__", - CONF_SCAN_INTERVAL: 120, - }, - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unknown" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - DOMAIN, "deprecated_yaml_import_issue_unknown" - ) - assert issue.translation_key == "deprecated_yaml_import_issue" - - -@patch("pydrawise.legacy.LegacyHydrawise") async def test_flow_import_already_imported( - mock_api: MagicMock, hass: HomeAssistant + hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User ) -> None: """Test that we can handle a YAML config already imported.""" mock_config_entry = MockConfigEntry( @@ -187,12 +175,12 @@ async def test_flow_import_already_imported( data={ CONF_API_KEY: "__api_key__", }, - unique_id="hydrawise-CUSTOMER_ID", + unique_id="hydrawise-12345", ) mock_config_entry.add_to_hass(hass) - mock_api.return_value.customer_id = "CUSTOMER_ID" - mock_api.return_value.status = "All good!" + mock_pydrawise.get_user.return_value = user + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 79cea94d479..6b41867b044 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -1,8 +1,8 @@ """Tests for the Hydrawise integration.""" -from unittest.mock import Mock +from unittest.mock import AsyncMock -from requests.exceptions import HTTPError +from aiohttp import ClientError from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN @@ -13,11 +13,10 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) -> None: +async def test_setup_import_success( + hass: HomeAssistant, mock_pydrawise: AsyncMock +) -> None: """Test that setup with a YAML config triggers an import and warning.""" - mock_pydrawise.update_controller_info.return_value = True - mock_pydrawise.customer_id = 12345 - mock_pydrawise.status = "unknown" config = {"hydrawise": {CONF_ACCESS_TOKEN: "_access-token_"}} assert await async_setup_component(hass, "hydrawise", config) await hass.async_block_till_done() @@ -30,21 +29,10 @@ async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) - async def test_connect_retry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: AsyncMock ) -> None: """Test that a connection error triggers a retry.""" - mock_pydrawise.update_controller_info.side_effect = HTTPError - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_no_data( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock -) -> None: - """Test that no data from the API triggers a retry.""" - mock_pydrawise.update_controller_info.return_value = False + mock_pydrawise.get_user.side_effect = ClientError 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/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py index c6d3fecab65..f0edb79b349 100644 --- a/tests/components/hydrawise/test_sensor.py +++ b/tests/components/hydrawise/test_sensor.py @@ -1,6 +1,9 @@ """Test Hydrawise sensor.""" +from collections.abc import Awaitable, Callable + from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Zone import pytest from homeassistant.core import HomeAssistant @@ -26,3 +29,18 @@ async def test_states( next_cycle = hass.states.get("sensor.zone_one_next_cycle") assert next_cycle is not None assert next_cycle.state == "2023-10-04T19:49:57+00:00" + + +@pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") +async def test_suspended_state( + hass: HomeAssistant, + zones: list[Zone], + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], +) -> None: + """Test sensor states.""" + zones[0].scheduled_runs.next_run = None + await mock_add_config_entry() + + next_cycle = hass.states.get("sensor.zone_one_next_cycle") + assert next_cycle is not None + assert next_cycle.state == "9999-12-31T23:59:59+00:00" diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index 1d2de7f8332..30a58735122 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -1,12 +1,16 @@ """Test Hydrawise switch.""" -from unittest.mock import Mock +from datetime import timedelta +from unittest.mock import AsyncMock -from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Zone +import pytest +from homeassistant.components.hydrawise.const import DEFAULT_WATERING_TIME from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -14,7 +18,6 @@ from tests.common import MockConfigEntry async def test_states( hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test switch states.""" watering1 = hass.states.get("switch.zone_one_manual_watering") @@ -31,11 +34,14 @@ async def test_states( auto_watering2 = hass.states.get("switch.zone_two_automatic_watering") assert auto_watering2 is not None - assert auto_watering2.state == "off" + assert auto_watering2.state == "on" async def test_manual_watering_services( - hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + zones: list[Zone], ) -> None: """Test Manual Watering services.""" await hass.services.async_call( @@ -44,7 +50,9 @@ async def test_manual_watering_services( service_data={ATTR_ENTITY_ID: "switch.zone_one_manual_watering"}, blocking=True, ) - mock_pydrawise.run_zone.assert_called_once_with(15, 1) + mock_pydrawise.start_zone.assert_called_once_with( + zones[0], custom_run_duration=DEFAULT_WATERING_TIME + ) state = hass.states.get("switch.zone_one_manual_watering") assert state is not None assert state.state == "on" @@ -56,14 +64,18 @@ async def test_manual_watering_services( service_data={ATTR_ENTITY_ID: "switch.zone_one_manual_watering"}, blocking=True, ) - mock_pydrawise.run_zone.assert_called_once_with(0, 1) + mock_pydrawise.stop_zone.assert_called_once_with(zones[0]) state = hass.states.get("switch.zone_one_manual_watering") assert state is not None assert state.state == "off" +@pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") async def test_auto_watering_services( - hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + zones: list[Zone], ) -> None: """Test Automatic Watering services.""" await hass.services.async_call( @@ -72,7 +84,9 @@ async def test_auto_watering_services( service_data={ATTR_ENTITY_ID: "switch.zone_one_automatic_watering"}, blocking=True, ) - mock_pydrawise.suspend_zone.assert_called_once_with(365, 1) + mock_pydrawise.suspend_zone.assert_called_once_with( + zones[0], dt_util.now() + timedelta(days=365) + ) state = hass.states.get("switch.zone_one_automatic_watering") assert state is not None assert state.state == "off" @@ -84,7 +98,7 @@ async def test_auto_watering_services( service_data={ATTR_ENTITY_ID: "switch.zone_one_automatic_watering"}, blocking=True, ) - mock_pydrawise.suspend_zone.assert_called_once_with(0, 1) + mock_pydrawise.resume_zone.assert_called_once_with(zones[0]) state = hass.states.get("switch.zone_one_automatic_watering") assert state is not None assert state.state == "on" From 422b09f4ec12f5ecc3c9c217094520ecd18b801b Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Thu, 16 Nov 2023 01:42:55 +0100 Subject: [PATCH 521/982] Bump python-holidays to 0.36 (#104055) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 1c9a533d998..c7c993e70d0 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.35"] + "requirements": ["holidays==0.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index 57985bbf20d..7852cbbadd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1015,7 +1015,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.35 +holidays==0.36 # homeassistant.components.frontend home-assistant-frontend==20231030.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b84037a2f84..421e0db26d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -799,7 +799,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.35 +holidays==0.36 # homeassistant.components.frontend home-assistant-frontend==20231030.2 From 613afe322f10889200fe60252977c5a3ee1f0007 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 15 Nov 2023 16:57:46 -0800 Subject: [PATCH 522/982] Add CalDAV To-do item support for Add, Update, and Delete (#103922) * Add CalDAV To-do item support for Add, Update, and Delete * Remove unnecessary cast * Fix ruff error * Fix ruff errors * Remove exception from error message * Remove unnecessary duplicate state update --- homeassistant/components/caldav/todo.py | 84 +++++- tests/components/caldav/test_todo.py | 358 +++++++++++++++++++++++- 2 files changed, 433 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 887f760399b..eddfe410100 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -1,15 +1,25 @@ """CalDAV todo platform.""" from __future__ import annotations +import asyncio from datetime import timedelta from functools import partial import logging +from typing import cast import caldav +from caldav.lib.error import DAVError, NotFoundError +import requests -from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .api import async_get_calendars, get_attr_value @@ -26,6 +36,10 @@ TODO_STATUS_MAP = { "COMPLETED": TodoItemStatus.COMPLETED, "CANCELLED": TodoItemStatus.COMPLETED, } +TODO_STATUS_MAP_INV: dict[TodoItemStatus, str] = { + TodoItemStatus.NEEDS_ACTION: "NEEDS-ACTION", + TodoItemStatus.COMPLETED: "COMPLETED", +} async def async_setup_entry( @@ -71,6 +85,11 @@ class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: """Initialize WebDavTodoListEntity.""" @@ -92,3 +111,66 @@ class WebDavTodoListEntity(TodoListEntity): for resource in results if (todo_item := _todo_item(resource)) is not None ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + try: + await self.hass.async_add_executor_job( + partial( + self._calendar.save_todo, + summary=item.summary, + status=TODO_STATUS_MAP_INV.get( + item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION" + ), + ), + ) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV save error: {err}") from err + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + try: + todo = await self.hass.async_add_executor_job( + self._calendar.todo_by_uid, uid + ) + except NotFoundError as err: + raise HomeAssistantError(f"Could not find To-do item {uid}") from err + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV lookup error: {err}") from err + vtodo = todo.icalendar_component # type: ignore[attr-defined] + if item.summary: + vtodo["summary"] = item.summary + if item.status: + vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION") + try: + await self.hass.async_add_executor_job( + partial( + todo.save, + no_create=True, + obj_type="todo", + ), + ) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV save error: {err}") from err + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete To-do items.""" + tasks = ( + self.hass.async_add_executor_job(self._calendar.todo_by_uid, uid) + for uid in uids + ) + + try: + items = await asyncio.gather(*tasks) + except NotFoundError as err: + raise HomeAssistantError("Could not find To-do item") from err + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV lookup error: {err}") from err + + # Run serially as some CalDAV servers do not support concurrent modifications + for item in items: + try: + await self.hass.async_add_executor_job(item.delete) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV delete error: {err}") from err diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 352b60d5ed3..31901515e5a 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -1,17 +1,22 @@ """The tests for the webdav todo component.""" +from typing import Any from unittest.mock import MagicMock, Mock +from caldav.lib.error import DAVError, NotFoundError from caldav.objects import Todo import pytest +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry CALENDAR_NAME = "My Tasks" ENTITY_NAME = "My tasks" TEST_ENTITY = "todo.my_tasks" +SUPPORTED_FEATURES = 7 TODO_NO_STATUS = """BEGIN:VCALENDAR VERSION:2.0 @@ -75,17 +80,32 @@ def mock_supported_components() -> list[str]: return ["VTODO"] -@pytest.fixture(name="calendars") -def mock_calendars(todos: list[str], supported_components: list[str]) -> list[Mock]: - """Fixture to create calendars for the test.""" +@pytest.fixture(name="calendar") +def mock_calendar(supported_components: list[str]) -> Mock: + """Fixture to create the primary calendar for the test.""" calendar = Mock() - items = [ - Todo(None, f"{idx}.ics", item, calendar, str(idx)) - for idx, item in enumerate(todos) - ] - calendar.search = MagicMock(return_value=items) + calendar.search = MagicMock(return_value=[]) calendar.name = CALENDAR_NAME calendar.get_supported_components = MagicMock(return_value=supported_components) + return calendar + + +def create_todo(calendar: Mock, idx: str, ics: str) -> Todo: + """Create a caldav Todo object.""" + return Todo(client=None, url=f"{idx}.ics", data=ics, parent=calendar, id=idx) + + +@pytest.fixture(autouse=True) +def mock_search_items(calendar: Mock, todos: list[str]) -> None: + """Fixture to add search results to the test calendar.""" + calendar.search.return_value = [ + create_todo(calendar, str(idx), item) for idx, item in enumerate(todos) + ] + + +@pytest.fixture(name="calendars") +def mock_calendars(calendar: Mock) -> list[Mock]: + """Fixture to create calendars for the test.""" return [calendar] @@ -137,6 +157,7 @@ async def test_todo_list_state( assert state.state == expected_state assert dict(state.attributes) == { "friendly_name": ENTITY_NAME, + "supported_features": SUPPORTED_FEATURES, } @@ -154,3 +175,324 @@ async def test_supported_components( state = hass.states.get(TEST_ENTITY) assert (state is not None) == has_entity + + +async def test_add_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendar: Mock, +) -> None: + """Test adding an item to the list.""" + calendar.search.return_value = [] + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + # Simulat return value for the state update after the service call + calendar.search.return_value = [create_todo(calendar, "2", TODO_NEEDS_ACTION)] + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert calendar.save_todo.call_args + assert calendar.save_todo.call_args.kwargs == { + "status": "NEEDS-ACTION", + "summary": "Cheese", + } + + # Verify state was updated + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_add_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendar: Mock, +) -> None: + """Test failure when adding an item to the list.""" + await config_entry.async_setup(hass) + + calendar.save_todo.side_effect = DAVError() + + with pytest.raises(HomeAssistantError, match="CalDAV save error"): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("update_data", "expected_ics", "expected_state"), + [ + ( + {"rename": "Swiss Cheese"}, + ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + "1", + ), + ({"status": "needs_action"}, ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], "1"), + ({"status": "completed"}, ["SUMMARY:Cheese", "STATUS:COMPLETED"], "0"), + ( + {"rename": "Swiss Cheese", "status": "needs_action"}, + ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + "1", + ), + ], +) +async def test_update_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + update_data: dict[str, Any], + expected_ics: list[str], + expected_state: str, +) -> None: + """Test creating a an item on the list.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + calendar.todo_by_uid = MagicMock(return_value=item) + + dav_client.put.return_value.status = 204 + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + **update_data, + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert dav_client.put.call_args + ics = dav_client.put.call_args.args[1] + for expected in expected_ics: + assert expected in ics + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == expected_state + + +async def test_update_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test failure when updating an item on the list.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + calendar.todo_by_uid = MagicMock(return_value=item) + dav_client.put.side_effect = DAVError() + + with pytest.raises(HomeAssistantError, match="CalDAV save error"): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("side_effect", "match"), + [(DAVError, "CalDAV lookup error"), (NotFoundError, "Could not find")], +) +async def test_update_item_lookup_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + side_effect: Any, + match: str, +) -> None: + """Test failure when looking up an item to update.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + calendar.todo_by_uid.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("uids_to_delete", "expect_item1_delete_called", "expect_item2_delete_called"), + [ + ([], False, False), + (["Cheese"], True, False), + (["Wine"], False, True), + (["Wine", "Cheese"], True, True), + ], + ids=("none", "item1-only", "item2-only", "both-items"), +) +async def test_remove_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + uids_to_delete: list[str], + expect_item1_delete_called: bool, + expect_item2_delete_called: bool, +) -> None: + """Test removing an item on the list.""" + + item1 = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + item2 = Todo(dav_client, None, TODO_COMPLETED, calendar, "3") + calendar.search = MagicMock(return_value=[item1, item2]) + + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + def lookup(uid: str) -> Mock: + assert uid == "2" or uid == "3" + if uid == "2": + return item1 + return item2 + + calendar.todo_by_uid = Mock(side_effect=lookup) + item1.delete = Mock() + item2.delete = Mock() + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": uids_to_delete}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert item1.delete.called == expect_item1_delete_called + assert item2.delete.called == expect_item2_delete_called + + +@pytest.mark.parametrize( + ("todos", "side_effect", "match"), + [ + ([TODO_NEEDS_ACTION], DAVError, "CalDAV lookup error"), + ([TODO_NEEDS_ACTION], NotFoundError, "Could not find"), + ], +) +async def test_remove_item_lookup_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendar: Mock, + side_effect: Any, + match: str, +) -> None: + """Test failure while removing an item from the list.""" + + await config_entry.async_setup(hass) + + calendar.todo_by_uid.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +async def test_remove_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test removing an item on the list.""" + + item = Todo(dav_client, "2.ics", TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + def lookup(uid: str) -> Mock: + return item + + calendar.todo_by_uid = Mock(side_effect=lookup) + dav_client.delete.return_value.status = 500 + + with pytest.raises(HomeAssistantError, match="CalDAV delete error"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +async def test_remove_item_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test removing an item on the list.""" + + item = Todo(dav_client, "2.ics", TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + def lookup(uid: str) -> Mock: + return item + + calendar.todo_by_uid.side_effect = NotFoundError() + + with pytest.raises(HomeAssistantError, match="Could not find"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) From 1c817cc18cd0d8e338d185e895172c510661d43e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Nov 2023 07:25:52 +0100 Subject: [PATCH 523/982] Attach relevant config to check_config errors (#104048) --- homeassistant/helpers/check_config.py | 17 ++++++++++------- tests/helpers/test_check_config.py | 7 ++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index e177d30d94d..72bddedd5e4 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -102,7 +102,10 @@ async def async_check_ha_config_file( # noqa: C901 result.add_warning(message, domain, pack_config) def _comp_error( - ex: vol.Invalid | HomeAssistantError, domain: str, component_config: ConfigType + ex: vol.Invalid | HomeAssistantError, + domain: str, + component_config: ConfigType, + config_to_attach: ConfigType, ) -> None: """Handle errors from components.""" if isinstance(ex, vol.Invalid): @@ -110,9 +113,9 @@ async def async_check_ha_config_file( # noqa: C901 else: message = format_homeassistant_error(ex, domain, component_config) if domain in frontend_dependencies: - result.add_error(message, domain, component_config) + result.add_error(message, domain, config_to_attach) else: - result.add_warning(message, domain, component_config) + result.add_warning(message, domain, config_to_attach) async def _get_integration( hass: HomeAssistant, domain: str @@ -207,7 +210,7 @@ async def async_check_ha_config_file( # noqa: C901 )[domain] continue except (vol.Invalid, HomeAssistantError) as ex: - _comp_error(ex, domain, config) + _comp_error(ex, domain, config, config[domain]) continue except Exception as err: # pylint: disable=broad-except logging.getLogger(__name__).exception( @@ -228,7 +231,7 @@ async def async_check_ha_config_file( # noqa: C901 if domain in config: result[domain] = config[domain] except vol.Invalid as ex: - _comp_error(ex, domain, config) + _comp_error(ex, domain, config, config[domain]) continue component_platform_schema = getattr( @@ -246,7 +249,7 @@ async def async_check_ha_config_file( # noqa: C901 try: p_validated = component_platform_schema(p_config) except vol.Invalid as ex: - _comp_error(ex, domain, p_config) + _comp_error(ex, domain, p_config, p_config) continue # Not all platform components follow same pattern for platforms @@ -282,7 +285,7 @@ async def async_check_ha_config_file( # noqa: C901 try: p_validated = platform_schema(p_validated) except vol.Invalid as ex: - _comp_error(ex, f"{domain}.{p_name}", p_config) + _comp_error(ex, f"{domain}.{p_name}", p_config, p_config) continue platforms.append(p_validated) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 8a49f23cf46..df1aa4f2d2c 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -442,21 +442,19 @@ action: @pytest.mark.parametrize( - ("exception", "errors", "warnings", "message", "config"), + ("exception", "errors", "warnings", "message"), [ ( Exception("Broken"), 1, 0, "Unexpected error calling config validator: Broken", - {"value": 1}, ), ( HomeAssistantError("Broken"), 0, 1, "Invalid config for [bla]: Broken", - {"bla": {"value": 1}}, ), ], ) @@ -466,7 +464,6 @@ async def test_config_platform_raise( errors: int, warnings: int, message: str, - config: dict, ) -> None: """Test bad config validation platform.""" mock_platform( @@ -486,7 +483,7 @@ bla: error = CheckConfigError( message, "bla", - config, + {"value": 1}, ) _assert_warnings_errors(res, [error] * warnings, [error] * errors) From e10c5246b943ba3965dfbae9436e2755930f7d44 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Thu, 16 Nov 2023 02:47:13 -0500 Subject: [PATCH 524/982] Add reauth support to Schlage (#103351) * Add reauth support to Schlage * Enforce same user credentials are used on reauth * Changes requested during review * Changes requested during review * Add password to reauth_confirm data --- homeassistant/components/schlage/__init__.py | 6 +- .../components/schlage/config_flow.py | 99 ++++++++++++++----- .../components/schlage/coordinator.py | 7 +- homeassistant/components/schlage/strings.json | 11 ++- tests/components/schlage/conftest.py | 6 +- tests/components/schlage/test_config_flow.py | 93 +++++++++++++++++ tests/components/schlage/test_init.py | 37 ++++++- 7 files changed, 228 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index feaa95864d5..96ff32d3e85 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -7,8 +7,9 @@ import pyschlage from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import SchlageDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -26,8 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: auth = await hass.async_add_executor_job(pyschlage.Auth, username, password) except WarrantException as ex: - LOGGER.error("Schlage authentication failed: %s", ex) - return False + raise ConfigEntryAuthFailed from ex coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py index 7e095466087..84bc3ef8ef6 100644 --- a/homeassistant/components/schlage/config_flow.py +++ b/homeassistant/components/schlage/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Schlage integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import pyschlage @@ -8,6 +9,7 @@ from pyschlage.exceptions import NotAuthorizedError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult @@ -16,6 +18,7 @@ from .const import DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -23,36 +26,88 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} - if user_input is not None: - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - try: - user_id = await self.hass.async_add_executor_job( - _authenticate, username, password - ) - except NotAuthorizedError: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unknown error") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(user_id) - return self.async_create_entry(title=username, data=user_input) + if user_input is None: + return self._show_user_form({}) + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + user_id, errors = await self.hass.async_add_executor_job( + _authenticate, username, password + ) + if user_id is None: + return self._show_user_form(errors) + await self.async_set_unique_id(user_id) + return self.async_create_entry(title=username, data=user_input) + + def _show_user_form(self, errors: dict[str, str]) -> FlowResult: + """Show the user form.""" return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() -def _authenticate(username: str, password: str) -> str: + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self.reauth_entry is not None + if user_input is None: + return self._show_reauth_form({}) + + username = self.reauth_entry.data[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + user_id, errors = await self.hass.async_add_executor_job( + _authenticate, username, password + ) + if user_id is None: + return self._show_reauth_form(errors) + + if self.reauth_entry.unique_id != user_id: + return self.async_abort(reason="wrong_account") + + data = { + CONF_USERNAME: username, + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + def _show_reauth_form(self, errors: dict[str, str]) -> FlowResult: + """Show the reauth form.""" + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) + + +def _authenticate(username: str, password: str) -> tuple[str | None, dict[str, str]]: """Authenticate with the Schlage API.""" - auth = pyschlage.Auth(username, password) - auth.authenticate() - # The user_id property will make a blocking call if it's not already - # cached. To avoid blocking the event loop, we read it here. - return auth.user_id + user_id = None + errors: dict[str, str] = {} + try: + auth = pyschlage.Auth(username, password) + auth.authenticate() + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + # The user_id property will make a blocking call if it's not already + # cached. To avoid blocking the event loop, we read it here. + user_id = auth.user_id + return user_id, errors diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index 2b1e8460af2..3d736306d91 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -5,10 +5,11 @@ import asyncio from dataclasses import dataclass from pyschlage import Lock, Schlage -from pyschlage.exceptions import Error as SchlageError +from pyschlage.exceptions import Error as SchlageError, NotAuthorizedError from pyschlage.log import LockLog from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -43,6 +44,8 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): """Fetch the latest data from the Schlage API.""" try: locks = await self.hass.async_add_executor_job(self.api.locks) + except NotAuthorizedError as ex: + raise ConfigEntryAuthFailed from ex except SchlageError as ex: raise UpdateFailed("Failed to refresh Schlage data") from ex lock_data = await asyncio.gather( @@ -64,6 +67,8 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): logs = previous_lock_data.logs try: logs = lock.logs() + except NotAuthorizedError as ex: + raise ConfigEntryAuthFailed from ex except SchlageError as ex: LOGGER.debug('Failed to read logs for lock "%s": %s', lock.name, ex) diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 076ed97e298..721d9e80286 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -6,6 +6,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Schlage integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -13,7 +20,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "The user credentials provided do not match this Schlage account." } }, "entity": { diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 7b610a6b4da..5f9676b7d09 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -54,14 +54,14 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_schlage(): +def mock_schlage() -> Mock: """Mock pyschlage.Schlage.""" with patch("pyschlage.Schlage", autospec=True) as mock_schlage: yield mock_schlage.return_value @pytest.fixture -def mock_pyschlage_auth(): +def mock_pyschlage_auth() -> Mock: """Mock pyschlage.Auth.""" with patch("pyschlage.Auth", autospec=True) as mock_auth: mock_auth.return_value.user_id = "abc123" @@ -69,7 +69,7 @@ def mock_pyschlage_auth(): @pytest.fixture -def mock_lock(): +def mock_lock() -> Mock: """Mock Lock fixture.""" mock_lock = create_autospec(Lock) mock_lock.configure_mock( diff --git a/tests/components/schlage/test_config_flow.py b/tests/components/schlage/test_config_flow.py index b256e8950ed..14121f5d9ca 100644 --- a/tests/components/schlage/test_config_flow.py +++ b/tests/components/schlage/test_config_flow.py @@ -9,6 +9,8 @@ from homeassistant.components.schlage.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -78,3 +80,94 @@ async def test_form_unknown(hass: HomeAssistant, mock_pyschlage_auth: Mock) -> N assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} + + +async def test_reauth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_pyschlage_auth: Mock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "new-password"}, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_added_config_entry.data == { + "username": "asdf@asdf.com", + "password": "new-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_invalid_auth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_pyschlage_auth: Mock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_pyschlage_auth.authenticate.reset_mock() + mock_pyschlage_auth.authenticate.side_effect = NotAuthorizedError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "new-password"}, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_pyschlage_auth: Mock, +) -> None: + """Test reauth flow.""" + mock_pyschlage_auth.user_id = "bad-user-id" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "new-password"}, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "wrong_account" + assert mock_added_config_entry.data == { + "username": "asdf@asdf.com", + "password": "hunter2", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py index 0811d87ec80..0fe7af1982b 100644 --- a/tests/components/schlage/test_init.py +++ b/tests/components/schlage/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import Mock, patch from pycognito.exceptions import WarrantException -from pyschlage.exceptions import Error +from pyschlage.exceptions import Error, NotAuthorizedError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -43,6 +43,41 @@ async def test_update_data_fails( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_update_data_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, +) -> None: + """Test that we properly handle API errors.""" + mock_schlage.locks.side_effect = NotAuthorizedError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_schlage.locks.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_update_data_get_logs_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, + mock_lock: Mock, +) -> None: + """Test that we properly handle API errors.""" + mock_schlage.locks.return_value = [mock_lock] + mock_lock.logs.reset_mock() + mock_lock.logs.side_effect = NotAuthorizedError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_schlage.locks.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 86f87262edc50668e5b74c7e5fc7d5391b13fbaf Mon Sep 17 00:00:00 2001 From: gigatexel <65073191+gigatexel@users.noreply.github.com> Date: Thu, 16 Nov 2023 08:59:29 +0100 Subject: [PATCH 525/982] Remove force_update from all DSMR entities (#104037) Remove force_update --- homeassistant/components/dsmr/sensor.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index d4dfde274d1..696698cc176 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -82,7 +82,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="current_electricity_usage", obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE, device_class=SensorDeviceClass.POWER, - force_update=True, state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( @@ -90,7 +89,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="current_electricity_delivery", obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY, device_class=SensorDeviceClass.POWER, - force_update=True, state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( @@ -108,7 +106,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENERGY, - force_update=True, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( @@ -116,7 +113,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_used_tariff_2", obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -125,7 +121,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_delivered_tariff_1", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -134,7 +129,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_delivered_tariff_2", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -334,7 +328,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_imported_total", obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -343,7 +336,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_exported_total", obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -352,7 +344,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="current_average_demand", obis_reference=obis_references.BELGIUM_CURRENT_AVERAGE_DEMAND, dsmr_versions={"5B"}, - force_update=True, device_class=SensorDeviceClass.POWER, ), DSMRSensorEntityDescription( @@ -360,7 +351,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="maximum_demand_current_month", obis_reference=obis_references.BELGIUM_MAXIMUM_DEMAND_MONTH, dsmr_versions={"5B"}, - force_update=True, device_class=SensorDeviceClass.POWER, ), DSMRSensorEntityDescription( @@ -369,7 +359,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference=obis_references.HOURLY_GAS_METER_READING, dsmr_versions={"4", "5", "5L"}, is_gas=True, - force_update=True, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -379,7 +368,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference=obis_references.GAS_METER_READING, dsmr_versions={"2.2"}, is_gas=True, - force_update=True, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -405,7 +393,6 @@ def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescri obis_reference=ref, dsmr_versions={"5B"}, is_gas=True, - force_update=True, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, ) From 98030a9ce1d123696ef4ad411208c65e5bd5737d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Nov 2023 09:08:47 +0100 Subject: [PATCH 526/982] Improve formatting of package errors (#103976) --- homeassistant/config.py | 30 +++++++++----- homeassistant/helpers/check_config.py | 2 +- .../packages/configuration.yaml | 4 ++ .../configuration.yaml | 3 ++ .../integrations/unknown_integration.yaml | 1 + tests/helpers/test_check_config.py | 2 +- tests/snapshots/test_config.ambr | 28 +++++++++++-- tests/test_config.py | 40 +++++++++++++++++++ 8 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/core/config/package_exceptions/packages/configuration.yaml create mode 100644 tests/fixtures/core/config/package_exceptions/packages_include_dir_named/configuration.yaml create mode 100644 tests/fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml diff --git a/homeassistant/config.py b/homeassistant/config.py index 272ad59d1f4..2176c111867 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -84,11 +84,7 @@ SCRIPT_CONFIG_PATH = "scripts.yaml" SCENE_CONFIG_PATH = "scenes.yaml" LOAD_EXCEPTIONS = (ImportError, FileNotFoundError) -INTEGRATION_LOAD_EXCEPTIONS = ( - IntegrationNotFound, - RequirementsNotFound, - *LOAD_EXCEPTIONS, -) +INTEGRATION_LOAD_EXCEPTIONS = (IntegrationNotFound, RequirementsNotFound) SAFE_MODE_FILENAME = "safe-mode" @@ -812,7 +808,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non def _log_pkg_error(package: str, component: str, config: dict, message: str) -> None: """Log an error while merging packages.""" - message = f"Package {package} setup failed. Integration {component} {message}" + message = f"Package {package} setup failed. {message}" pack_config = config[CONF_CORE][CONF_PACKAGES].get(package, config) message += ( @@ -897,7 +893,7 @@ async def merge_packages_config( hass: HomeAssistant, config: dict, packages: dict[str, Any], - _log_pkg_error: Callable = _log_pkg_error, + _log_pkg_error: Callable[[str, str, dict, str], None] = _log_pkg_error, ) -> dict: """Merge packages into the top-level configuration. Mutate config.""" PACKAGES_CONFIG_SCHEMA(packages) @@ -914,6 +910,14 @@ async def merge_packages_config( hass, domain ) component = integration.get_component() + except LOAD_EXCEPTIONS as ex: + _log_pkg_error( + pack_name, + comp_name, + config, + f"Integration {comp_name} caused error: {str(ex)}", + ) + continue except INTEGRATION_LOAD_EXCEPTIONS as ex: _log_pkg_error(pack_name, comp_name, config, str(ex)) continue @@ -949,7 +953,10 @@ async def merge_packages_config( if not isinstance(comp_conf, dict): _log_pkg_error( - pack_name, comp_name, config, "cannot be merged. Expected a dict." + pack_name, + comp_name, + config, + f"Integration {comp_name} cannot be merged. Expected a dict.", ) continue @@ -961,14 +968,17 @@ async def merge_packages_config( pack_name, comp_name, config, - "cannot be merged. Dict expected in main config.", + f"Integration {comp_name} cannot be merged. Dict expected in main config.", ) continue duplicate_key = _recursive_merge(conf=config[comp_name], package=comp_conf) if duplicate_key: _log_pkg_error( - pack_name, comp_name, config, f"has duplicate key '{duplicate_key}'" + pack_name, + comp_name, + config, + f"Integration {comp_name} has duplicate key '{duplicate_key}'.", ) return config diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 72bddedd5e4..ddc8dce9ec8 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -96,7 +96,7 @@ async def async_check_ha_config_file( # noqa: C901 package: str, component: str, config: ConfigType, message: str ) -> None: """Handle errors from packages.""" - message = f"Package {package} setup failed. Component {component} {message}" + message = f"Package {package} setup failed. {message}" domain = f"homeassistant.packages.{package}.{component}" pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_warning(message, domain, pack_config) diff --git a/tests/fixtures/core/config/package_exceptions/packages/configuration.yaml b/tests/fixtures/core/config/package_exceptions/packages/configuration.yaml new file mode 100644 index 00000000000..bf2a79c1307 --- /dev/null +++ b/tests/fixtures/core/config/package_exceptions/packages/configuration.yaml @@ -0,0 +1,4 @@ +homeassistant: + packages: + pack_1: + test_domain: diff --git a/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/configuration.yaml new file mode 100644 index 00000000000..d3b52e4d49d --- /dev/null +++ b/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/configuration.yaml @@ -0,0 +1,3 @@ +homeassistant: + # Load packages + packages: !include_dir_named integrations diff --git a/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml b/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml new file mode 100644 index 00000000000..66a70375f70 --- /dev/null +++ b/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml @@ -0,0 +1 @@ +test_domain: diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index df1aa4f2d2c..589f71a791d 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -380,7 +380,7 @@ async def test_package_invalid(hass: HomeAssistant) -> None: warning = CheckConfigError( ( - "Package p1 setup failed. Component group cannot be merged. Expected a " + "Package p1 setup failed. Integration group cannot be merged. Expected a " "dict." ), "homeassistant.packages.p1.group", diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 19ff83f0d08..bc0dd8830e0 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -314,16 +314,36 @@ list([ 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages/configuration.yaml:9).', 'Package pack_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages/configuration.yaml:13).', - "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages/configuration.yaml:20).", - "Package pack_5 setup failed. Integration unknown_integration Integration 'unknown_integration' not found. (See /fixtures/core/config/package_errors/packages/configuration.yaml:23).", + "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host'. (See /fixtures/core/config/package_errors/packages/configuration.yaml:20).", + "Package pack_5 setup failed. Integration 'unknown_integration' not found. (See /fixtures/core/config/package_errors/packages/configuration.yaml:23).", ]) # --- # name: test_package_merge_error[packages_include_dir_named] list([ 'Package adr_0007_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml:2).', 'Package adr_0007_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml:2).', - "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host' (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml:1).", - "Package unknown_integration setup failed. Integration unknown_integration Integration 'unknown_integration' not found. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml:2).", + "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host'. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml:1).", + "Package unknown_integration setup failed. Integration 'unknown_integration' not found. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml:2).", + ]) +# --- +# name: test_package_merge_exception[packages-error0] + list([ + "Package pack_1 setup failed. Integration test_domain caused error: No such file or directory: b'liblibc.a' (See /fixtures/core/config/package_exceptions/packages/configuration.yaml:4).", + ]) +# --- +# name: test_package_merge_exception[packages-error1] + list([ + "Package pack_1 setup failed. Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something' (See /fixtures/core/config/package_exceptions/packages/configuration.yaml:4).", + ]) +# --- +# name: test_package_merge_exception[packages_include_dir_named-error0] + list([ + "Package unknown_integration setup failed. Integration test_domain caused error: No such file or directory: b'liblibc.a' (See /fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml:1).", + ]) +# --- +# name: test_package_merge_exception[packages_include_dir_named-error1] + list([ + "Package unknown_integration setup failed. Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something' (See /fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml:1).", ]) # --- # name: test_yaml_error[basic] diff --git a/tests/test_config.py b/tests/test_config.py index d39a1d4e907..dab8243bb39 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1818,6 +1818,46 @@ async def test_package_merge_error( assert error_records == snapshot +@pytest.mark.parametrize( + "error", + [ + FileNotFoundError("No such file or directory: b'liblibc.a'"), + ImportError( + ("ModuleNotFoundError: No module named 'not_installed_something'"), + name="not_installed_something", + ), + ], +) +@pytest.mark.parametrize( + "config_dir", + ["packages", "packages_include_dir_named"], +) +async def test_package_merge_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + error: Exception, + snapshot: SnapshotAssertion, +) -> None: + """Test exception when merging packages.""" + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "package_exceptions", config_dir + ) + with patch( + "homeassistant.config.async_get_integration_with_requirements", + side_effect=error, + ): + await config_util.async_hass_config_yaml(hass) + + error_records = [ + record.message.replace(base_path, "") + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot + + @pytest.mark.parametrize( "config_dir", [ From d8a49b14e5c229bdaa3b0fb61976eb7afe5ecb3d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Nov 2023 10:56:47 +0100 Subject: [PATCH 527/982] Use relative paths in configuration validation error messages (#104064) --- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/config.py | 49 ++++-- homeassistant/helpers/check_config.py | 12 +- tests/helpers/test_check_config.py | 5 +- tests/snapshots/test_config.ambr | 178 +++++++++++----------- tests/test_config.py | 8 +- 6 files changed, 141 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 931615bf0ac..83e6dae55b1 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -249,7 +249,7 @@ async def async_check_config_schema( integration = await async_get_integration(hass, DOMAIN) # pylint: disable-next=protected-access message = conf_util.format_schema_error( - ex, domain, config, integration.documentation + hass, ex, domain, config, integration.documentation ) raise ServiceValidationError( message, diff --git a/homeassistant/config.py b/homeassistant/config.py index 2176c111867..a33acead870 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -496,7 +496,7 @@ def async_log_schema_error( """Log a schema validation error.""" if hass is not None: async_notify_setup_error(hass, domain, link) - message = format_schema_error(ex, domain, config, link) + message = format_schema_error(hass, ex, domain, config, link) _LOGGER.error(message) @@ -590,7 +590,13 @@ def find_annotation( return find_annotation_rec(config, list(path), None) +def _relpath(hass: HomeAssistant, path: str) -> str: + """Return path relative to the Home Assistant config dir.""" + return os.path.relpath(path, hass.config.config_dir) + + def stringify_invalid( + hass: HomeAssistant, ex: vol.Invalid, domain: str, config: dict, @@ -613,7 +619,7 @@ def stringify_invalid( else: message_suffix = "" if annotation := find_annotation(config, ex.path): - message_prefix += f" at {annotation[0]}, line {annotation[1]}" + message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" path = "->".join(str(m) for m in ex.path) if ex.error_message == "extra keys not allowed": return ( @@ -643,6 +649,7 @@ def stringify_invalid( def humanize_error( + hass: HomeAssistant, validation_error: vol.Invalid, domain: str, config: dict, @@ -657,12 +664,14 @@ def humanize_error( if isinstance(validation_error, vol.MultipleInvalid): return "\n".join( sorted( - humanize_error(sub_error, domain, config, link, max_sub_error_length) + humanize_error( + hass, sub_error, domain, config, link, max_sub_error_length + ) for sub_error in validation_error.errors ) ) return stringify_invalid( - validation_error, domain, config, link, max_sub_error_length + hass, validation_error, domain, config, link, max_sub_error_length ) @@ -681,10 +690,14 @@ def format_homeassistant_error( @callback def format_schema_error( - ex: vol.Invalid, domain: str, config: dict, link: str | None = None + hass: HomeAssistant, + ex: vol.Invalid, + domain: str, + config: dict, + link: str | None = None, ) -> str: """Format configuration validation error.""" - return humanize_error(ex, domain, config, link) + return humanize_error(hass, ex, domain, config, link) async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> None: @@ -806,15 +819,19 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non hac.units = get_unit_system(config[CONF_UNIT_SYSTEM]) -def _log_pkg_error(package: str, component: str, config: dict, message: str) -> None: +def _log_pkg_error( + hass: HomeAssistant, package: str, component: str, config: dict, message: str +) -> None: """Log an error while merging packages.""" message = f"Package {package} setup failed. {message}" pack_config = config[CONF_CORE][CONF_PACKAGES].get(package, config) - message += ( - f" (See {getattr(pack_config, '__config_file__', '?')}:" - f"{getattr(pack_config, '__line__', '?')})." - ) + config_file = getattr(pack_config, "__config_file__", None) + if config_file: + config_file = _relpath(hass, config_file) + else: + config_file = "?" + message += f" (See {config_file}:{getattr(pack_config, '__line__', '?')})." _LOGGER.error(message) @@ -893,7 +910,9 @@ async def merge_packages_config( hass: HomeAssistant, config: dict, packages: dict[str, Any], - _log_pkg_error: Callable[[str, str, dict, str], None] = _log_pkg_error, + _log_pkg_error: Callable[ + [HomeAssistant, str, str, dict, str], None + ] = _log_pkg_error, ) -> dict: """Merge packages into the top-level configuration. Mutate config.""" PACKAGES_CONFIG_SCHEMA(packages) @@ -912,6 +931,7 @@ async def merge_packages_config( component = integration.get_component() except LOAD_EXCEPTIONS as ex: _log_pkg_error( + hass, pack_name, comp_name, config, @@ -919,7 +939,7 @@ async def merge_packages_config( ) continue except INTEGRATION_LOAD_EXCEPTIONS as ex: - _log_pkg_error(pack_name, comp_name, config, str(ex)) + _log_pkg_error(hass, pack_name, comp_name, config, str(ex)) continue try: @@ -953,6 +973,7 @@ async def merge_packages_config( if not isinstance(comp_conf, dict): _log_pkg_error( + hass, pack_name, comp_name, config, @@ -965,6 +986,7 @@ async def merge_packages_config( if not isinstance(config[comp_name], dict): _log_pkg_error( + hass, pack_name, comp_name, config, @@ -975,6 +997,7 @@ async def merge_packages_config( duplicate_key = _recursive_merge(conf=config[comp_name], package=comp_conf) if duplicate_key: _log_pkg_error( + hass, pack_name, comp_name, config, diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index ddc8dce9ec8..7466ddc6179 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -93,7 +93,11 @@ async def async_check_ha_config_file( # noqa: C901 async_clear_install_history(hass) def _pack_error( - package: str, component: str, config: ConfigType, message: str + hass: HomeAssistant, + package: str, + component: str, + config: ConfigType, + message: str, ) -> None: """Handle errors from packages.""" message = f"Package {package} setup failed. {message}" @@ -109,7 +113,7 @@ async def async_check_ha_config_file( # noqa: C901 ) -> None: """Handle errors from components.""" if isinstance(ex, vol.Invalid): - message = format_schema_error(ex, domain, component_config) + message = format_schema_error(hass, ex, domain, component_config) else: message = format_homeassistant_error(ex, domain, component_config) if domain in frontend_dependencies: @@ -158,7 +162,9 @@ async def async_check_ha_config_file( # noqa: C901 result[CONF_CORE] = core_config except vol.Invalid as err: result.add_error( - format_schema_error(err, CONF_CORE, core_config), CONF_CORE, core_config + format_schema_error(hass, err, CONF_CORE, core_config), + CONF_CORE, + core_config, ) core_config = {} diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 589f71a791d..82500cb0b30 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -82,9 +82,8 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: error = CheckConfigError( ( - "Invalid config for [homeassistant] at " - f"{hass.config.path(YAML_CONFIG_FILE)}, line 2: " - "not a valid value for dictionary value 'unit_system', got 'bad'." + f"Invalid config for [homeassistant] at {YAML_CONFIG_FILE}, line 2:" + " not a valid value for dictionary value 'unit_system', got 'bad'." ), "homeassistant", {"unit_system": "bad"}, diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index bc0dd8830e0..97faac40bf5 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -3,47 +3,47 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key 'platform' not provided.", + 'message': "Invalid config for [iot_domain] at configuration.yaml, line 6: required key 'platform' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 18: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key 'host' not provided.", + 'message': "Invalid config for [adr_0007_2] at configuration.yaml, line 27: required key 'host' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + 'message': "Invalid config for [adr_0007_3] at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + 'message': "Invalid config for [adr_0007_4] at configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key 'host' not provided. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + Invalid config for [adr_0007_5] at configuration.yaml, line 43: required key 'host' not provided. + Invalid config for [adr_0007_5] at configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 52: required key 'host' not provided.", + 'message': "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 52: required key 'host' not provided.", }), dict({ 'has_exc_info': True, @@ -59,47 +59,47 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5: required key 'platform' not provided.", + 'message': "Invalid config for [iot_domain] at integrations/iot_domain.yaml, line 5: required key 'platform' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 17: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123. + Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 17: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123. ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key 'host' not provided.", + 'message': "Invalid config for [adr_0007_2] at configuration.yaml, line 3: required key 'host' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + 'message': "Invalid config for [adr_0007_3] at integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + 'message': "Invalid config for [adr_0007_4] at integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 6: required key 'host' not provided. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + Invalid config for [adr_0007_5] at configuration.yaml, line 6: required key 'host' not provided. + Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo'. ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 8: required key 'host' not provided.", + 'message': "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 8: required key 'host' not provided.", }), dict({ 'has_exc_info': True, @@ -115,22 +115,22 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2: required key 'platform' not provided.", + 'message': "Invalid config for [iot_domain] at iot_domain/iot_domain_2.yaml, line 2: required key 'platform' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123. + Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123. ''', }), dict({ @@ -147,22 +147,22 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml, line 5: required key 'platform' not provided.", + 'message': "Invalid config for [iot_domain] at iot_domain/iot_domain_1.yaml, line 5: required key 'platform' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123. + Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123. ''', }), dict({ @@ -179,47 +179,47 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 11: required key 'platform' not provided.", + 'message': "Invalid config for [iot_domain] at configuration.yaml, line 11: required key 'platform' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 29: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 31: expected str for dictionary value 'option2', got 123. + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 29: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123. ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 38: required key 'host' not provided.", + 'message': "Invalid config for [adr_0007_2] at configuration.yaml, line 38: required key 'host' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + 'message': "Invalid config for [adr_0007_3] at configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 48: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + 'message': "Invalid config for [adr_0007_4] at configuration.yaml, line 48: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 54: required key 'host' not provided. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + Invalid config for [adr_0007_5] at configuration.yaml, line 54: required key 'host' not provided. + Invalid config for [adr_0007_5] at configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo'. ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 64: required key 'host' not provided.", + 'message': "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 64: required key 'host' not provided.", }), dict({ 'has_exc_info': True, @@ -235,47 +235,47 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6: required key 'platform' not provided.", + 'message': "Invalid config for [iot_domain] at integrations/iot_domain.yaml, line 6: required key 'platform' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 18: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123. + Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 18: required key 'option1' not provided. + Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option + Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123. ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key 'host' not provided.", + 'message': "Invalid config for [adr_0007_2] at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + 'message': "Invalid config for [adr_0007_3] at integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + 'message': "Invalid config for [adr_0007_4] at integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 5: required key 'host' not provided. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 5: required key 'host' not provided. + Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option + Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo'. ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml, line 2: required key 'host' not provided.", + 'message': "Invalid config for [custom_validator_ok_2] at integrations/custom_validator_ok_2.yaml, line 2: required key 'host' not provided.", }), dict({ 'has_exc_info': True, @@ -289,61 +289,61 @@ # --- # name: test_component_config_validation_error_with_docs[basic] list([ - "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 6: required key 'platform' not provided. Please check the docs at https://www.home-assistant.io/integrations/iot_domain.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: expected str for dictionary value 'option1', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007.", - "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for [iot_domain] at configuration.yaml, line 6: required key 'platform' not provided. Please check the docs at https://www.home-assistant.io/integrations/iot_domain.", + "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007.", + "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", ''' - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 18: required key 'option1' not provided. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 18: required key 'option1' not provided. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. ''', - "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 27: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_2.", - "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_3.", - "Invalid config for [adr_0007_4] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", + "Invalid config for [adr_0007_2] at configuration.yaml, line 27: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_2.", + "Invalid config for [adr_0007_3] at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_3.", + "Invalid config for [adr_0007_4] at configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", ''' - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 43: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 - Invalid config for [adr_0007_5] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. + Invalid config for [adr_0007_5] at configuration.yaml, line 43: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. + Invalid config for [adr_0007_5] at configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 + Invalid config for [adr_0007_5] at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. ''', - "Invalid config for [custom_validator_ok_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 52: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/custom_validator_ok_2.", + "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 52: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/custom_validator_ok_2.", 'Invalid config for [custom_validator_bad_1]: broken Please check the docs at https://www.home-assistant.io/integrations/custom_validator_bad_1.', 'Unknown error calling custom_validator_bad_2 config validator', ]) # --- # name: test_package_merge_error[packages] list([ - 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages/configuration.yaml:9).', - 'Package pack_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages/configuration.yaml:13).', - "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host'. (See /fixtures/core/config/package_errors/packages/configuration.yaml:20).", - "Package pack_5 setup failed. Integration 'unknown_integration' not found. (See /fixtures/core/config/package_errors/packages/configuration.yaml:23).", + 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See configuration.yaml:9).', + 'Package pack_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See configuration.yaml:13).', + "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host'. (See configuration.yaml:20).", + "Package pack_5 setup failed. Integration 'unknown_integration' not found. (See configuration.yaml:23).", ]) # --- # name: test_package_merge_error[packages_include_dir_named] list([ - 'Package adr_0007_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml:2).', - 'Package adr_0007_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml:2).', - "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host'. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml:1).", - "Package unknown_integration setup failed. Integration 'unknown_integration' not found. (See /fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml:2).", + 'Package adr_0007_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See integrations/adr_0007_1.yaml:2).', + 'Package adr_0007_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See integrations/adr_0007_2.yaml:2).', + "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host'. (See integrations/adr_0007_3_2.yaml:1).", + "Package unknown_integration setup failed. Integration 'unknown_integration' not found. (See integrations/unknown_integration.yaml:2).", ]) # --- # name: test_package_merge_exception[packages-error0] list([ - "Package pack_1 setup failed. Integration test_domain caused error: No such file or directory: b'liblibc.a' (See /fixtures/core/config/package_exceptions/packages/configuration.yaml:4).", + "Package pack_1 setup failed. Integration test_domain caused error: No such file or directory: b'liblibc.a' (See configuration.yaml:4).", ]) # --- # name: test_package_merge_exception[packages-error1] list([ - "Package pack_1 setup failed. Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something' (See /fixtures/core/config/package_exceptions/packages/configuration.yaml:4).", + "Package pack_1 setup failed. Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something' (See configuration.yaml:4).", ]) # --- # name: test_package_merge_exception[packages_include_dir_named-error0] list([ - "Package unknown_integration setup failed. Integration test_domain caused error: No such file or directory: b'liblibc.a' (See /fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml:1).", + "Package unknown_integration setup failed. Integration test_domain caused error: No such file or directory: b'liblibc.a' (See integrations/unknown_integration.yaml:1).", ]) # --- # name: test_package_merge_exception[packages_include_dir_named-error1] list([ - "Package unknown_integration setup failed. Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something' (See /fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml:1).", + "Package unknown_integration setup failed. Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something' (See integrations/unknown_integration.yaml:1).", ]) # --- # name: test_yaml_error[basic] diff --git a/tests/test_config.py b/tests/test_config.py index dab8243bb39..8d74d53c162 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1730,7 +1730,7 @@ async def test_component_config_validation_error( error_records = [ { - "message": record.message.replace(base_path, ""), + "message": record.message, "has_exc_info": bool(record.exc_info), } for record in caplog.get_records("call") @@ -1783,7 +1783,7 @@ async def test_component_config_validation_error_with_docs( ) error_records = [ - record.message.replace(base_path, "") + record.message for record in caplog.get_records("call") if record.levelno == logging.ERROR ] @@ -1811,7 +1811,7 @@ async def test_package_merge_error( await config_util.async_hass_config_yaml(hass) error_records = [ - record.message.replace(base_path, "") + record.message for record in caplog.get_records("call") if record.levelno == logging.ERROR ] @@ -1851,7 +1851,7 @@ async def test_package_merge_exception( await config_util.async_hass_config_yaml(hass) error_records = [ - record.message.replace(base_path, "") + record.message for record in caplog.get_records("call") if record.levelno == logging.ERROR ] From 654c4b6e35352eeafbc03345b724ac2328869176 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Nov 2023 11:26:57 +0100 Subject: [PATCH 528/982] Use core domain constant in bootstrap (#104061) --- homeassistant/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 288af779fba..4e0a0a5dd44 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -292,7 +292,7 @@ async def async_from_config_dict( try: await conf_util.async_process_ha_core_config(hass, core_config) except vol.Invalid as config_err: - conf_util.async_log_schema_error(config_err, "homeassistant", core_config, hass) + conf_util.async_log_schema_error(config_err, core.DOMAIN, core_config, hass) return None except HomeAssistantError: _LOGGER.error( From b4797e283f6cacb9f462ced58fbef64e1f5c2ef4 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Thu, 16 Nov 2023 20:45:18 +0800 Subject: [PATCH 529/982] Add HTTP protocol support to AsusWRT (#95720) --- homeassistant/components/asuswrt/bridge.py | 123 +++++++++++ .../components/asuswrt/config_flow.py | 207 ++++++++++++------ homeassistant/components/asuswrt/const.py | 2 + .../components/asuswrt/manifest.json | 2 +- homeassistant/components/asuswrt/router.py | 4 +- homeassistant/components/asuswrt/strings.json | 23 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/asuswrt/common.py | 15 +- tests/components/asuswrt/conftest.py | 70 +++++- tests/components/asuswrt/test_config_flow.py | 201 ++++++++++++++--- tests/components/asuswrt/test_sensor.py | 146 ++++++++++-- 12 files changed, 667 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index bbde9271984..83f99ecc76a 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -9,6 +9,8 @@ import logging from typing import Any, TypeVar, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy +from aiohttp import ClientSession +from pyasuswrt import AsusWrtError, AsusWrtHttp from homeassistant.const import ( CONF_HOST, @@ -19,6 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import UpdateFailed @@ -31,6 +34,8 @@ from .const import ( DEFAULT_INTERFACE, KEY_METHOD, KEY_SENSORS, + PROTOCOL_HTTP, + PROTOCOL_HTTPS, PROTOCOL_TELNET, SENSORS_BYTES, SENSORS_LOAD_AVG, @@ -74,6 +79,8 @@ def handle_errors_and_zip( raise UpdateFailed("Received invalid data type") return data + if isinstance(data, dict): + return dict(zip(keys, list(data.values()))) if not isinstance(data, list): raise UpdateFailed("Received invalid data type") return dict(zip(keys, data)) @@ -91,6 +98,9 @@ class AsusWrtBridge(ABC): hass: HomeAssistant, conf: dict[str, Any], options: dict[str, Any] | None = None ) -> AsusWrtBridge: """Get Bridge instance.""" + if conf[CONF_PROTOCOL] in (PROTOCOL_HTTPS, PROTOCOL_HTTP): + session = async_get_clientsession(hass) + return AsusWrtHttpBridge(conf, session) return AsusWrtLegacyBridge(conf, options) def __init__(self, host: str) -> None: @@ -286,3 +296,116 @@ class AsusWrtLegacyBridge(AsusWrtBridge): async def _get_temperatures(self) -> Any: """Fetch temperatures information from the router.""" return await self._api.async_get_temperature() + + +class AsusWrtHttpBridge(AsusWrtBridge): + """The Bridge that use HTTP library.""" + + def __init__(self, conf: dict[str, Any], session: ClientSession) -> None: + """Initialize Bridge that use HTTP library.""" + super().__init__(conf[CONF_HOST]) + self._api: AsusWrtHttp = self._get_api(conf, session) + + @staticmethod + def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusWrtHttp: + """Get the AsusWrtHttp API.""" + return AsusWrtHttp( + conf[CONF_HOST], + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ""), + use_https=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS, + port=conf.get(CONF_PORT), + session=session, + ) + + @property + def is_connected(self) -> bool: + """Get connected status.""" + return cast(bool, self._api.is_connected) + + async def async_connect(self) -> None: + """Connect to the device.""" + await self._api.async_connect() + + # get main router properties + if mac := self._api.mac: + self._label_mac = format_mac(mac) + self._firmware = self._api.firmware + self._model = self._api.model + + async def async_disconnect(self) -> None: + """Disconnect to the device.""" + await self._api.async_disconnect() + + async def async_get_connected_devices(self) -> dict[str, WrtDevice]: + """Get list of connected devices.""" + try: + api_devices = await self._api.async_get_connected_devices() + except AsusWrtError as exc: + raise UpdateFailed(exc) from exc + return { + format_mac(mac): WrtDevice(dev.ip, dev.name, dev.node) + for mac, dev in api_devices.items() + } + + async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: + """Return a dictionary of available sensors for this bridge.""" + sensors_temperatures = await self._get_available_temperature_sensors() + sensors_types = { + SENSORS_TYPE_BYTES: { + KEY_SENSORS: SENSORS_BYTES, + KEY_METHOD: self._get_bytes, + }, + SENSORS_TYPE_LOAD_AVG: { + KEY_SENSORS: SENSORS_LOAD_AVG, + KEY_METHOD: self._get_load_avg, + }, + SENSORS_TYPE_RATES: { + KEY_SENSORS: SENSORS_RATES, + KEY_METHOD: self._get_rates, + }, + SENSORS_TYPE_TEMPERATURES: { + KEY_SENSORS: sensors_temperatures, + KEY_METHOD: self._get_temperatures, + }, + } + return sensors_types + + async def _get_available_temperature_sensors(self) -> list[str]: + """Check which temperature information is available on the router.""" + try: + available_temps = await self._api.async_get_temperatures() + available_sensors = [ + t for t in SENSORS_TEMPERATURES if t in available_temps + ] + except AsusWrtError as exc: + _LOGGER.warning( + ( + "Failed checking temperature sensor availability for ASUS router" + " %s. Exception: %s" + ), + self.host, + exc, + ) + return [] + return available_sensors + + @handle_errors_and_zip(AsusWrtError, SENSORS_BYTES) + async def _get_bytes(self) -> Any: + """Fetch byte information from the router.""" + return await self._api.async_get_traffic_bytes() + + @handle_errors_and_zip(AsusWrtError, SENSORS_RATES) + async def _get_rates(self) -> Any: + """Fetch rates information from the router.""" + return await self._api.async_get_traffic_rates() + + @handle_errors_and_zip(AsusWrtError, SENSORS_LOAD_AVG) + async def _get_load_avg(self) -> Any: + """Fetch cpu load avg information from the router.""" + return await self._api.async_get_loadavg() + + @handle_errors_and_zip(AsusWrtError, None) + async def _get_temperatures(self) -> Any: + """Fetch temperatures information from the router.""" + return await self._api.async_get_temperatures() diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 56569d4f23b..047e9b549d8 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -7,6 +7,7 @@ import os import socket from typing import Any, cast +from pyasuswrt import AsusWrtError import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -15,6 +16,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( + CONF_BASE, CONF_HOST, CONF_MODE, CONF_PASSWORD, @@ -30,6 +32,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .bridge import AsusWrtBridge from .const import ( @@ -44,11 +47,21 @@ from .const import ( DOMAIN, MODE_AP, MODE_ROUTER, + PROTOCOL_HTTP, + PROTOCOL_HTTPS, PROTOCOL_SSH, PROTOCOL_TELNET, ) -LABEL_MAC = "LABEL_MAC" +ALLOWED_PROTOCOL = [ + PROTOCOL_HTTPS, + PROTOCOL_SSH, + PROTOCOL_HTTP, + PROTOCOL_TELNET, +] + +PASS_KEY = "pass_key" +PASS_KEY_MSG = "Only provide password or SSH key file" RESULT_CONN_ERROR = "cannot_connect" RESULT_SUCCESS = "success" @@ -56,14 +69,20 @@ RESULT_UNKNOWN = "unknown" _LOGGER = logging.getLogger(__name__) +LEGACY_SCHEMA = vol.Schema( + { + vol.Required(CONF_MODE, default=MODE_ROUTER): vol.In( + {MODE_ROUTER: "Router", MODE_AP: "Access Point"} + ), + } +) + OPTIONS_SCHEMA = vol.Schema( { vol.Optional( CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds() ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), vol.Optional(CONF_TRACK_UNKNOWN, default=DEFAULT_TRACK_UNKNOWN): bool, - vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): str, - vol.Required(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): str, } ) @@ -72,12 +91,22 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Get options schema.""" options_flow: SchemaOptionsFlowHandler options_flow = cast(SchemaOptionsFlowHandler, handler.parent_handler) - if options_flow.config_entry.data[CONF_MODE] == MODE_AP: - return OPTIONS_SCHEMA.extend( + used_protocol = options_flow.config_entry.data[CONF_PROTOCOL] + if used_protocol in [PROTOCOL_SSH, PROTOCOL_TELNET]: + data_schema = OPTIONS_SCHEMA.extend( { - vol.Optional(CONF_REQUIRE_IP, default=True): bool, + vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): str, + vol.Required(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): str, } ) + if options_flow.config_entry.data[CONF_MODE] == MODE_AP: + return data_schema.extend( + { + vol.Optional(CONF_REQUIRE_IP, default=True): bool, + } + ) + return data_schema + return OPTIONS_SCHEMA @@ -101,45 +130,47 @@ def _get_ip(host: str) -> str | None: class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" + """Handle a config flow for AsusWRT.""" VERSION = 1 + def __init__(self) -> None: + """Initialize the AsusWrt config flow.""" + self._config_data: dict[str, Any] = {} + @callback - def _show_setup_form( - self, - user_input: dict[str, Any] | None = None, - errors: dict[str, str] | None = None, - ) -> FlowResult: + def _show_setup_form(self, error: str | None = None) -> FlowResult: """Show the setup form to the user.""" - if user_input is None: - user_input = {} + user_input = self._config_data - adv_schema = {} - conf_password = vol.Required(CONF_PASSWORD) if self.show_advanced_options: - conf_password = vol.Optional(CONF_PASSWORD) - adv_schema[vol.Optional(CONF_PORT)] = cv.port - adv_schema[vol.Optional(CONF_SSH_KEY)] = str + add_schema = { + vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str, + vol.Optional(CONF_PORT): cv.port, + vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str, + } + else: + add_schema = {vol.Required(CONF_PASSWORD): str} schema = { vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str, - conf_password: str, - vol.Required(CONF_PROTOCOL, default=PROTOCOL_SSH): vol.In( - {PROTOCOL_SSH: "SSH", PROTOCOL_TELNET: "Telnet"} - ), - **adv_schema, - vol.Required(CONF_MODE, default=MODE_ROUTER): vol.In( - {MODE_ROUTER: "Router", MODE_AP: "Access Point"} + **add_schema, + vol.Required( + CONF_PROTOCOL, + default=user_input.get(CONF_PROTOCOL, PROTOCOL_HTTPS), + ): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_PROTOCOL, translation_key="protocols" + ) ), } return self.async_show_form( step_id="user", data_schema=vol.Schema(schema), - errors=errors or {}, + errors={CONF_BASE: error} if error else None, ) async def _async_check_connection( @@ -147,25 +178,49 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): ) -> tuple[str, str | None]: """Attempt to connect the AsusWrt router.""" + api: AsusWrtBridge host: str = user_input[CONF_HOST] - api = AsusWrtBridge.get_bridge(self.hass, user_input) + protocol = user_input[CONF_PROTOCOL] + error: str | None = None + + conf = {**user_input, CONF_MODE: MODE_ROUTER} + api = AsusWrtBridge.get_bridge(self.hass, conf) try: await api.async_connect() - except OSError: - _LOGGER.error("Error connecting to the AsusWrt router at %s", host) - return RESULT_CONN_ERROR, None + except (AsusWrtError, OSError): + _LOGGER.error( + "Error connecting to the AsusWrt router at %s using protocol %s", + host, + protocol, + ) + error = RESULT_CONN_ERROR except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with AsusWrt router at %s", host + "Unknown error connecting with AsusWrt router at %s using protocol %s", + host, + protocol, ) - return RESULT_UNKNOWN, None + error = RESULT_UNKNOWN - if not api.is_connected: - _LOGGER.error("Error connecting to the AsusWrt router at %s", host) - return RESULT_CONN_ERROR, None + if error is None: + if not api.is_connected: + _LOGGER.error( + "Error connecting to the AsusWrt router at %s using protocol %s", + host, + protocol, + ) + error = RESULT_CONN_ERROR + if error is not None: + return error, None + + _LOGGER.info( + "Successfully connected to the AsusWrt router at %s using protocol %s", + host, + protocol, + ) unique_id = api.label_mac await api.async_disconnect() @@ -182,51 +237,59 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="no_unique_id") if user_input is None: - return self._show_setup_form(user_input) - - errors: dict[str, str] = {} - host: str = user_input[CONF_HOST] + return self._show_setup_form() + self._config_data = user_input pwd: str | None = user_input.get(CONF_PASSWORD) ssh: str | None = user_input.get(CONF_SSH_KEY) + protocol: str = user_input[CONF_PROTOCOL] + if not pwd and protocol != PROTOCOL_SSH: + return self._show_setup_form(error="pwd_required") if not (pwd or ssh): - errors["base"] = "pwd_or_ssh" - elif ssh: - if pwd: - errors["base"] = "pwd_and_ssh" + return self._show_setup_form(error="pwd_or_ssh") + if ssh and not await self.hass.async_add_executor_job(_is_file, ssh): + return self._show_setup_form(error="ssh_not_file") + + host: str = user_input[CONF_HOST] + if not await self.hass.async_add_executor_job(_get_ip, host): + return self._show_setup_form(error="invalid_host") + + result, unique_id = await self._async_check_connection(user_input) + if result == RESULT_SUCCESS: + if unique_id: + await self.async_set_unique_id(unique_id) + # we allow to configure a single instance without unique id + elif self._async_current_entries(): + return self.async_abort(reason="invalid_unique_id") else: - isfile = await self.hass.async_add_executor_job(_is_file, ssh) - if not isfile: - errors["base"] = "ssh_not_file" - - if not errors: - ip_address = await self.hass.async_add_executor_job(_get_ip, host) - if not ip_address: - errors["base"] = "invalid_host" - - if not errors: - result, unique_id = await self._async_check_connection(user_input) - if result == RESULT_SUCCESS: - if unique_id: - await self.async_set_unique_id(unique_id) - # we allow configure a single instance without unique id - elif self._async_current_entries(): - return self.async_abort(reason="invalid_unique_id") - else: - _LOGGER.warning( - "This device does not provide a valid Unique ID." - " Configuration of multiple instance will not be possible" - ) - - return self.async_create_entry( - title=host, - data=user_input, + _LOGGER.warning( + "This device does not provide a valid Unique ID." + " Configuration of multiple instance will not be possible" ) - errors["base"] = result + if protocol in [PROTOCOL_SSH, PROTOCOL_TELNET]: + return await self.async_step_legacy() + return await self._async_save_entry() - return self._show_setup_form(user_input, errors) + return self._show_setup_form(error=result) + + async def async_step_legacy( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow for legacy settings.""" + if user_input is None: + return self.async_show_form(step_id="legacy", data_schema=LEGACY_SCHEMA) + + self._config_data.update(user_input) + return await self._async_save_entry() + + async def _async_save_entry(self) -> FlowResult: + """Save entry data if unique id is valid.""" + return self.async_create_entry( + title=self._config_data[CONF_HOST], + data=self._config_data, + ) @staticmethod @callback diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index 1733d4c09c3..a4cd6cde94c 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -20,6 +20,8 @@ KEY_SENSORS = "sensors" MODE_AP = "ap" MODE_ROUTER = "router" +PROTOCOL_HTTP = "http" +PROTOCOL_HTTPS = "https" PROTOCOL_SSH = "ssh" PROTOCOL_TELNET = "telnet" diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 39f88fb96fe..9ed09cee67f 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0"] + "requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.20"] } diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index c6fe651d292..927eef572f7 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -6,6 +6,8 @@ from datetime import datetime, timedelta import logging from typing import Any +from pyasuswrt import AsusWrtError + from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -219,7 +221,7 @@ class AsusWrtRouter: """Set up a AsusWrt router.""" try: await self._api.async_connect() - except OSError as exc: + except (AsusWrtError, OSError) as exc: raise ConfigEntryNotReady from exc if not self._api.is_connected: raise ConfigEntryNotReady diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 52b9f919434..cf105a6a708 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -6,21 +6,26 @@ "description": "Set required parameter to connect to your router", "data": { "host": "[%key:common::config_flow::data::host%]", - "name": "[%key:common::config_flow::data::name%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "ssh_key": "Path to your SSH key file (instead of password)", "protocol": "Communication protocol to use", - "port": "Port (leave empty for protocol default)", - "mode": "[%key:common::config_flow::data::mode%]" + "port": "Port (leave empty for protocol default)" + } + }, + "legacy": { + "title": "AsusWRT", + "description": "Set required parameters to connect to your router", + "data": { + "mode": "Router operating mode" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]", - "pwd_and_ssh": "Only provide password or SSH key file", "pwd_or_ssh": "Please provide password or SSH key file", + "pwd_required": "Password is required for selected protocol", "ssh_not_file": "SSH key file not found", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -79,5 +84,15 @@ "name": "CPU Temperature" } } + }, + "selector": { + "protocols": { + "options": { + "https": "HTTPS", + "http": "HTTP", + "ssh": "SSH", + "telnet": "Telnet" + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 7852cbbadd9..440e0ed180b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1610,6 +1610,9 @@ pyairnow==1.2.1 # homeassistant.components.airvisual_pro pyairvisual==2023.08.1 +# homeassistant.components.asuswrt +pyasuswrt==0.1.20 + # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 421e0db26d4..ebcd5123c92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1223,6 +1223,9 @@ pyairnow==1.2.1 # homeassistant.components.airvisual_pro pyairvisual==2023.08.1 +# homeassistant.components.asuswrt +pyasuswrt==0.1.20 + # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/tests/components/asuswrt/common.py b/tests/components/asuswrt/common.py index 8572584d65f..d3953416281 100644 --- a/tests/components/asuswrt/common.py +++ b/tests/components/asuswrt/common.py @@ -1,10 +1,13 @@ """Test code shared between test files.""" from aioasuswrt.asuswrt import Device as LegacyDevice +from pyasuswrt.asuswrt import Device as HttpDevice from homeassistant.components.asuswrt.const import ( CONF_SSH_KEY, MODE_ROUTER, + PROTOCOL_HTTP, + PROTOCOL_HTTPS, PROTOCOL_SSH, PROTOCOL_TELNET, ) @@ -40,6 +43,14 @@ CONFIG_DATA_SSH = { CONF_MODE: MODE_ROUTER, } +CONFIG_DATA_HTTP = { + CONF_HOST: HOST, + CONF_PORT: 80, + CONF_PROTOCOL: PROTOCOL_HTTPS, + CONF_USERNAME: "user", + CONF_PASSWORD: "pwd", +} + MOCK_MACS = [ "A1:B1:C1:D1:E1:F1", "A2:B2:C2:D2:E2:F2", @@ -48,6 +59,8 @@ MOCK_MACS = [ ] -def new_device(mac, ip, name): +def new_device(protocol, mac, ip, name): """Return a new device for specific protocol.""" + if protocol in [PROTOCOL_HTTP, PROTOCOL_HTTPS]: + return HttpDevice(mac, ip, name, ROUTER_MAC_ADDR, None) return LegacyDevice(mac, ip, name) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index ab574cd667f..0f29c84c820 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -4,16 +4,24 @@ from unittest.mock import Mock, patch from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aioasuswrt.connection import TelnetConnection +from pyasuswrt.asuswrt import AsusWrtError, AsusWrtHttp import pytest +from homeassistant.components.asuswrt.const import PROTOCOL_HTTP, PROTOCOL_SSH + from .common import ASUSWRT_BASE, MOCK_MACS, ROUTER_MAC_ADDR, new_device +ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtHttp" ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" MOCK_BYTES_TOTAL = [60000000000, 50000000000] +MOCK_BYTES_TOTAL_HTTP = dict(enumerate(MOCK_BYTES_TOTAL)) MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] -MOCK_LOAD_AVG = [1.1, 1.2, 1.3] -MOCK_TEMPERATURES = {"2.4GHz": 40.2, "5.0GHz": 0, "CPU": 71.2} +MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) +MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} +MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) +MOCK_TEMPERATURES_HTTP = {"2.4GHz": 40.2, "CPU": 71.2} +MOCK_TEMPERATURES = {**MOCK_TEMPERATURES_HTTP, "5.0GHz": 0} @pytest.fixture(name="patch_setup_entry") @@ -29,8 +37,17 @@ def mock_controller_patch_setup_entry(): def mock_devices_legacy_fixture(): """Mock a list of devices.""" return { - MOCK_MACS[0]: new_device(MOCK_MACS[0], "192.168.1.2", "Test"), - MOCK_MACS[1]: new_device(MOCK_MACS[1], "192.168.1.3", "TestTwo"), + MOCK_MACS[0]: new_device(PROTOCOL_SSH, MOCK_MACS[0], "192.168.1.2", "Test"), + MOCK_MACS[1]: new_device(PROTOCOL_SSH, MOCK_MACS[1], "192.168.1.3", "TestTwo"), + } + + +@pytest.fixture(name="mock_devices_http") +def mock_devices_http_fixture(): + """Mock a list of devices.""" + return { + MOCK_MACS[0]: new_device(PROTOCOL_HTTP, MOCK_MACS[0], "192.168.1.2", "Test"), + MOCK_MACS[1]: new_device(PROTOCOL_HTTP, MOCK_MACS[1], "192.168.1.3", "TestTwo"), } @@ -81,3 +98,48 @@ def mock_controller_connect_legacy_sens_fail(connect_legacy): True, True, ] + + +@pytest.fixture(name="connect_http") +def mock_controller_connect_http(mock_devices_http): + """Mock a successful connection with http library.""" + with patch(ASUSWRT_HTTP_LIB, spec_set=AsusWrtHttp) as service_mock: + service_mock.return_value.is_connected = True + service_mock.return_value.mac = ROUTER_MAC_ADDR + service_mock.return_value.model = "FAKE_MODEL" + service_mock.return_value.firmware = "FAKE_FIRMWARE" + service_mock.return_value.async_get_connected_devices.return_value = ( + mock_devices_http + ) + service_mock.return_value.async_get_traffic_bytes.return_value = ( + MOCK_BYTES_TOTAL_HTTP + ) + service_mock.return_value.async_get_traffic_rates.return_value = ( + MOCK_CURRENT_TRANSFER_RATES_HTTP + ) + service_mock.return_value.async_get_loadavg.return_value = MOCK_LOAD_AVG_HTTP + service_mock.return_value.async_get_temperatures.return_value = ( + MOCK_TEMPERATURES_HTTP + ) + yield service_mock + + +@pytest.fixture(name="connect_http_sens_fail") +def mock_controller_connect_http_sens_fail(connect_http): + """Mock a successful connection using http library with sensors fail.""" + connect_http.return_value.mac = None + connect_http.return_value.async_get_connected_devices.side_effect = AsusWrtError + connect_http.return_value.async_get_traffic_bytes.side_effect = AsusWrtError + connect_http.return_value.async_get_traffic_rates.side_effect = AsusWrtError + connect_http.return_value.async_get_loadavg.side_effect = AsusWrtError + connect_http.return_value.async_get_temperatures.side_effect = AsusWrtError + + +@pytest.fixture(name="connect_http_sens_detect") +def mock_controller_connect_http_sens_detect(): + """Mock a successful sensor detection using http library.""" + with patch( + f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_temperature_sensors", + return_value=[*MOCK_TEMPERATURES], + ) as mock_sens_detect: + yield mock_sens_detect diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index ec81c4a256a..0b5b0ace720 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -2,6 +2,7 @@ from socket import gaierror from unittest.mock import patch +from pyasuswrt import AsusWrtError import pytest from homeassistant import data_entry_flow @@ -13,18 +14,54 @@ from homeassistant.components.asuswrt.const import ( CONF_TRACK_UNKNOWN, DOMAIN, MODE_AP, + MODE_ROUTER, + PROTOCOL_HTTPS, + PROTOCOL_SSH, + PROTOCOL_TELNET, ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MODE, CONF_PASSWORD +from homeassistant.const import ( + CONF_BASE, + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant -from .common import ASUSWRT_BASE, CONFIG_DATA_TELNET, HOST, ROUTER_MAC_ADDR +from .common import ASUSWRT_BASE, HOST, ROUTER_MAC_ADDR from tests.common import MockConfigEntry SSH_KEY = "1234" +CONFIG_DATA = { + CONF_HOST: HOST, + CONF_USERNAME: "user", + CONF_PASSWORD: "pwd", +} + +CONFIG_DATA_HTTP = { + **CONFIG_DATA, + CONF_PROTOCOL: PROTOCOL_HTTPS, + CONF_PORT: 8443, +} + +CONFIG_DATA_SSH = { + **CONFIG_DATA, + CONF_PROTOCOL: PROTOCOL_SSH, + CONF_PORT: 22, +} + +CONFIG_DATA_TELNET = { + **CONFIG_DATA, + CONF_PROTOCOL: PROTOCOL_TELNET, + CONF_PORT: 23, +} + @pytest.fixture(name="patch_get_host", autouse=True) def mock_controller_patch_get_host(): @@ -45,7 +82,7 @@ def mock_controller_patch_is_file(): @pytest.mark.parametrize("unique_id", [{}, {"label_mac": ROUTER_MAC_ADDR}]) -async def test_user( +async def test_user_legacy( hass: HomeAssistant, connect_legacy, patch_setup_entry, unique_id ) -> None: """Test user config.""" @@ -58,30 +95,57 @@ async def test_user( connect_legacy.return_value.async_get_nvram.return_value = unique_id # test with all provided + legacy_result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA_TELNET + ) + await hass.async_block_till_done() + + assert legacy_result["type"] == data_entry_flow.FlowResultType.FORM + assert legacy_result["step_id"] == "legacy" + + # complete configuration result = await hass.config_entries.flow.async_configure( - flow_result["flow_id"], - user_input=CONFIG_DATA_TELNET, + legacy_result["flow_id"], user_input={CONF_MODE: MODE_AP} ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == HOST - assert result["data"] == CONFIG_DATA_TELNET + assert result["data"] == {**CONFIG_DATA_TELNET, CONF_MODE: MODE_AP} assert len(patch_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("config", "error"), - [ - ({}, "pwd_or_ssh"), - ({CONF_PASSWORD: "pwd", CONF_SSH_KEY: SSH_KEY}, "pwd_and_ssh"), - ], -) -async def test_error_wrong_password_ssh(hass: HomeAssistant, config, error) -> None: - """Test we abort for wrong password and ssh file combination.""" - config_data = {k: v for k, v in CONFIG_DATA_TELNET.items() if k != CONF_PASSWORD} - config_data.update(config) +@pytest.mark.parametrize("unique_id", [None, ROUTER_MAC_ADDR]) +async def test_user_http( + hass: HomeAssistant, connect_http, patch_setup_entry, unique_id +) -> None: + """Test user config http.""" + flow_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True} + ) + assert flow_result["type"] == data_entry_flow.FlowResultType.FORM + assert flow_result["step_id"] == "user" + + connect_http.return_value.mac = unique_id + + # test with all provided + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA_HTTP + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == CONFIG_DATA_HTTP + + assert len(patch_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("config", [CONFIG_DATA_TELNET, CONFIG_DATA_HTTP]) +async def test_error_pwd_required(hass: HomeAssistant, config) -> None: + """Test we abort for missing password.""" + config_data = {k: v for k, v in config.items() if k != CONF_PASSWORD} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, @@ -89,12 +153,25 @@ async def test_error_wrong_password_ssh(hass: HomeAssistant, config, error) -> N ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": error} + assert result["errors"] == {CONF_BASE: "pwd_required"} + + +async def test_error_no_password_ssh(hass: HomeAssistant) -> None: + """Test we abort for wrong password and ssh file combination.""" + config_data = {k: v for k, v in CONFIG_DATA_SSH.items() if k != CONF_PASSWORD} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: "pwd_or_ssh"} async def test_error_invalid_ssh(hass: HomeAssistant, patch_is_file) -> None: """Test we abort if invalid ssh file is provided.""" - config_data = {k: v for k, v in CONFIG_DATA_TELNET.items() if k != CONF_PASSWORD} + config_data = {k: v for k, v in CONFIG_DATA_SSH.items() if k != CONF_PASSWORD} config_data[CONF_SSH_KEY] = SSH_KEY patch_is_file.return_value = False @@ -105,7 +182,7 @@ async def test_error_invalid_ssh(hass: HomeAssistant, patch_is_file) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": "ssh_not_file"} + assert result["errors"] == {CONF_BASE: "ssh_not_file"} async def test_error_invalid_host(hass: HomeAssistant, patch_get_host) -> None: @@ -118,7 +195,7 @@ async def test_error_invalid_host(hass: HomeAssistant, patch_get_host) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {CONF_BASE: "invalid_host"} async def test_abort_if_not_unique_id_setup(hass: HomeAssistant) -> None: @@ -138,27 +215,26 @@ async def test_abort_if_not_unique_id_setup(hass: HomeAssistant) -> None: async def test_update_uniqueid_exist( - hass: HomeAssistant, connect_legacy, patch_setup_entry + hass: HomeAssistant, connect_http, patch_setup_entry ) -> None: """Test we update entry if uniqueid is already configured.""" existing_entry = MockConfigEntry( domain=DOMAIN, - data={**CONFIG_DATA_TELNET, CONF_HOST: "10.10.10.10"}, + data={**CONFIG_DATA_HTTP, CONF_HOST: "10.10.10.10"}, unique_id=ROUTER_MAC_ADDR, ) existing_entry.add_to_hass(hass) - # test with all provided result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, - data=CONFIG_DATA_TELNET, + data=CONFIG_DATA_HTTP, ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == HOST - assert result["data"] == CONFIG_DATA_TELNET + assert result["data"] == CONFIG_DATA_HTTP prev_entry = hass.config_entries.async_get_entry(existing_entry.entry_id) assert not prev_entry @@ -190,10 +266,10 @@ async def test_abort_invalid_unique_id(hass: HomeAssistant, connect_legacy) -> N (None, "cannot_connect"), ], ) -async def test_on_connect_failed( +async def test_on_connect_legacy_failed( hass: HomeAssistant, connect_legacy, side_effect, error ) -> None: - """Test when we have errors connecting the router.""" + """Test when we have errors connecting the router with legacy library.""" flow_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, @@ -202,11 +278,43 @@ async def test_on_connect_failed( connect_legacy.return_value.is_connected = False connect_legacy.return_value.connection.async_connect.side_effect = side_effect + # go to legacy form result = await hass.config_entries.flow.async_configure( flow_result["flow_id"], user_input=CONFIG_DATA_TELNET ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": error} + assert result["errors"] == {CONF_BASE: error} + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (AsusWrtError, "cannot_connect"), + (TypeError, "unknown"), + (None, "cannot_connect"), + ], +) +async def test_on_connect_http_failed( + hass: HomeAssistant, connect_http, side_effect, error +) -> None: + """Test when we have errors connecting the router with http library.""" + flow_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + ) + + connect_http.return_value.is_connected = False + connect_http.return_value.async_connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA_HTTP + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: error} async def test_options_flow_ap(hass: HomeAssistant, patch_setup_entry) -> None: @@ -251,7 +359,7 @@ async def test_options_flow_router(hass: HomeAssistant, patch_setup_entry) -> No """Test config flow options for router mode.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA_TELNET, + data={**CONFIG_DATA_TELNET, CONF_MODE: MODE_ROUTER}, ) config_entry.add_to_hass(hass) @@ -280,3 +388,36 @@ async def test_options_flow_router(hass: HomeAssistant, patch_setup_entry) -> No CONF_INTERFACE: "aaa", CONF_DNSMASQ: "bbb", } + + +async def test_options_flow_http(hass: HomeAssistant, patch_setup_entry) -> None: + """Test config flow options for http mode.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={**CONFIG_DATA_HTTP, CONF_MODE: MODE_ROUTER}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert CONF_INTERFACE not in result["data_schema"].schema + assert CONF_DNSMASQ not in result["data_schema"].schema + assert CONF_REQUIRE_IP not in result["data_schema"].schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + } diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index b2fa13101bc..a7b19bb3785 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -1,6 +1,7 @@ """Tests for the AsusWrt sensor.""" from datetime import timedelta +from pyasuswrt.asuswrt import AsusWrtError import pytest from homeassistant.components import device_tracker, sensor @@ -14,19 +15,32 @@ from homeassistant.components.asuswrt.const import ( ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_PROTOCOL, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import slugify from homeassistant.util.dt import utcnow -from .common import CONFIG_DATA_TELNET, HOST, MOCK_MACS, ROUTER_MAC_ADDR, new_device +from .common import ( + CONFIG_DATA_HTTP, + CONFIG_DATA_TELNET, + HOST, + MOCK_MACS, + ROUTER_MAC_ADDR, + new_device, +) from tests.common import MockConfigEntry, async_fire_time_changed SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] +SENSORS_ALL_HTTP = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] @pytest.fixture(name="create_device_registry_devices") @@ -132,8 +146,12 @@ async def _test_sensors( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "1" # add 2 new devices, one unnamed that should be ignored but counted - mock_devices[MOCK_MACS[2]] = new_device(MOCK_MACS[2], "192.168.1.4", "TestThree") - mock_devices[MOCK_MACS[3]] = new_device(MOCK_MACS[3], "192.168.1.5", None) + mock_devices[MOCK_MACS[2]] = new_device( + config[CONF_PROTOCOL], MOCK_MACS[2], "192.168.1.4", "TestThree" + ) + mock_devices[MOCK_MACS[3]] = new_device( + config[CONF_PROTOCOL], MOCK_MACS[3], "192.168.1.5", None + ) # change consider home settings to have status not home of removed tracked device hass.config_entries.async_update_entry( @@ -154,7 +172,7 @@ async def _test_sensors( "entry_unique_id", [None, ROUTER_MAC_ADDR], ) -async def test_sensors( +async def test_sensors_legacy( hass: HomeAssistant, connect_legacy, mock_devices_legacy, @@ -165,11 +183,24 @@ async def test_sensors( await _test_sensors(hass, mock_devices_legacy, CONFIG_DATA_TELNET, entry_unique_id) -async def test_loadavg_sensors(hass: HomeAssistant, connect_legacy) -> None: +@pytest.mark.parametrize( + "entry_unique_id", + [None, ROUTER_MAC_ADDR], +) +async def test_sensors_http( + hass: HomeAssistant, + connect_http, + mock_devices_http, + create_device_registry_devices, + entry_unique_id, +) -> None: + """Test creating AsusWRT default sensors and tracker with http protocol.""" + await _test_sensors(hass, mock_devices_http, CONFIG_DATA_HTTP, entry_unique_id) + + +async def _test_loadavg_sensors(hass: HomeAssistant, config) -> None: """Test creating an AsusWRT load average sensors.""" - config_entry, sensor_prefix = _setup_entry( - hass, CONFIG_DATA_TELNET, SENSORS_LOAD_AVG - ) + config_entry, sensor_prefix = _setup_entry(hass, config, SENSORS_LOAD_AVG) config_entry.add_to_hass(hass) # initial devices setup @@ -184,13 +215,40 @@ async def test_loadavg_sensors(hass: HomeAssistant, connect_legacy) -> None: assert hass.states.get(f"{sensor_prefix}_sensor_load_avg15").state == "1.3" -async def test_temperature_sensors(hass: HomeAssistant, connect_legacy) -> None: - """Test creating a AsusWRT temperature sensors.""" +async def test_loadavg_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: + """Test creating an AsusWRT load average sensors.""" + await _test_loadavg_sensors(hass, CONFIG_DATA_TELNET) + + +async def test_loadavg_sensors_http(hass: HomeAssistant, connect_http) -> None: + """Test creating an AsusWRT load average sensors.""" + await _test_loadavg_sensors(hass, CONFIG_DATA_HTTP) + + +async def test_temperature_sensors_http_fail( + hass: HomeAssistant, connect_http_sens_fail +) -> None: + """Test fail creating AsusWRT temperature sensors.""" config_entry, sensor_prefix = _setup_entry( - hass, CONFIG_DATA_TELNET, SENSORS_TEMPERATURES + hass, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES ) config_entry.add_to_hass(hass) + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # assert temperature availability exception is handled correctly + assert not hass.states.get(f"{sensor_prefix}_2_4ghz") + assert not hass.states.get(f"{sensor_prefix}_5_0ghz") + assert not hass.states.get(f"{sensor_prefix}_cpu") + + +async def _test_temperature_sensors(hass: HomeAssistant, config) -> None: + """Test creating a AsusWRT temperature sensors.""" + config_entry, sensor_prefix = _setup_entry(hass, config, SENSORS_TEMPERATURES) + config_entry.add_to_hass(hass) + # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -203,11 +261,23 @@ async def test_temperature_sensors(hass: HomeAssistant, connect_legacy) -> None: assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" +async def test_temperature_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: + """Test creating a AsusWRT temperature sensors.""" + await _test_temperature_sensors(hass, CONFIG_DATA_TELNET) + + +async def test_temperature_sensors_http(hass: HomeAssistant, connect_http) -> None: + """Test creating a AsusWRT temperature sensors.""" + await _test_temperature_sensors(hass, CONFIG_DATA_HTTP) + + @pytest.mark.parametrize( "side_effect", [OSError, None], ) -async def test_connect_fail(hass: HomeAssistant, connect_legacy, side_effect) -> None: +async def test_connect_fail_legacy( + hass: HomeAssistant, connect_legacy, side_effect +) -> None: """Test AsusWRT connect fail.""" # init config entry @@ -226,22 +296,43 @@ async def test_connect_fail(hass: HomeAssistant, connect_legacy, side_effect) -> assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_sensors_polling_fails( - hass: HomeAssistant, connect_legacy_sens_fail +@pytest.mark.parametrize( + "side_effect", + [AsusWrtError, None], +) +async def test_connect_fail_http( + hass: HomeAssistant, connect_http, side_effect ) -> None: - """Test AsusWRT sensors are unavailable when polling fails.""" - config_entry, sensor_prefix = _setup_entry( - hass, CONFIG_DATA_TELNET, SENSORS_ALL_LEGACY + """Test AsusWRT connect fail.""" + + # init config entry + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA_HTTP, ) config_entry.add_to_hass(hass) + connect_http.return_value.async_connect.side_effect = side_effect + connect_http.return_value.is_connected = False + + # initial setup fail + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def _test_sensors_polling_fails(hass: HomeAssistant, config, sensors) -> None: + """Test AsusWRT sensors are unavailable when polling fails.""" + config_entry, sensor_prefix = _setup_entry(hass, config, sensors) + config_entry.add_to_hass(hass) + # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - for sensor_name in SENSORS_ALL_LEGACY: + for sensor_name in sensors: assert ( hass.states.get(f"{sensor_prefix}_{slugify(sensor_name)}").state == STATE_UNAVAILABLE @@ -249,6 +340,23 @@ async def test_sensors_polling_fails( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "0" +async def test_sensors_polling_fails_legacy( + hass: HomeAssistant, + connect_legacy_sens_fail, +) -> None: + """Test AsusWRT sensors are unavailable when polling fails.""" + await _test_sensors_polling_fails(hass, CONFIG_DATA_TELNET, SENSORS_ALL_LEGACY) + + +async def test_sensors_polling_fails_http( + hass: HomeAssistant, + connect_http_sens_fail, + connect_http_sens_detect, +) -> None: + """Test AsusWRT sensors are unavailable when polling fails.""" + await _test_sensors_polling_fails(hass, CONFIG_DATA_HTTP, SENSORS_ALL_HTTP) + + async def test_options_reload(hass: HomeAssistant, connect_legacy) -> None: """Test AsusWRT integration is reload changing an options that require this.""" config_entry = MockConfigEntry( From 38961c6ddc55b7eae4081ae005788b205fa2f641 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Thu, 16 Nov 2023 13:36:15 +0000 Subject: [PATCH 530/982] Add diagnostics platform to ring integration (#104049) * Add diagnostics platform to ring integration * Use real-ish data for diagnostics test and use snapshot output --- homeassistant/components/ring/diagnostics.py | 43 ++ .../ring/snapshots/test_diagnostics.ambr | 579 ++++++++++++++++++ tests/components/ring/test_diagnostics.py | 24 + 3 files changed, 646 insertions(+) create mode 100644 homeassistant/components/ring/diagnostics.py create mode 100644 tests/components/ring/snapshots/test_diagnostics.ambr create mode 100644 tests/components/ring/test_diagnostics.py diff --git a/homeassistant/components/ring/diagnostics.py b/homeassistant/components/ring/diagnostics.py new file mode 100644 index 00000000000..f9624a76333 --- /dev/null +++ b/homeassistant/components/ring/diagnostics.py @@ -0,0 +1,43 @@ +"""Diagnostics support for Ring.""" +from __future__ import annotations + +from typing import Any + +import ring_doorbell + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import DOMAIN + +TO_REDACT = { + "id", + "device_id", + "description", + "first_name", + "last_name", + "email", + "location_id", + "ring_net_id", + "wifi_name", + "latitude", + "longitude", + "address", + "ring_id", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + ring: ring_doorbell.Ring = hass.data[DOMAIN][entry.entry_id]["api"] + devices_raw = [] + for device_type in ring.devices_data: + for device_id in ring.devices_data[device_type]: + devices_raw.append(ring.devices_data[device_type][device_id]) + return async_redact_data( + {"device_data": devices_raw}, + TO_REDACT, + ) diff --git a/tests/components/ring/snapshots/test_diagnostics.ambr b/tests/components/ring/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..64e753ba2b3 --- /dev/null +++ b/tests/components/ring/snapshots/test_diagnostics.ambr @@ -0,0 +1,579 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_data': list([ + dict({ + 'address': '**REDACTED**', + 'alerts': dict({ + 'connection': 'online', + }), + 'description': '**REDACTED**', + 'device_id': '**REDACTED**', + 'do_not_disturb': dict({ + 'seconds_left': 0, + }), + 'features': dict({ + 'ringtones_enabled': True, + }), + 'firmware_version': '1.2.3', + 'id': '**REDACTED**', + 'kind': 'chime', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'owned': True, + 'owner': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'id': '**REDACTED**', + 'last_name': '**REDACTED**', + }), + 'settings': dict({ + 'ding_audio_id': None, + 'ding_audio_user_id': None, + 'motion_audio_id': None, + 'motion_audio_user_id': None, + 'volume': 2, + }), + 'time_zone': 'America/New_York', + }), + dict({ + 'address': '**REDACTED**', + 'alerts': dict({ + 'connection': 'online', + }), + 'battery_life': 4081, + 'description': '**REDACTED**', + 'device_id': '**REDACTED**', + 'external_connection': False, + 'features': dict({ + 'advanced_motion_enabled': False, + 'motion_message_enabled': False, + 'motions_enabled': True, + 'people_only_enabled': False, + 'shadow_correction_enabled': False, + 'show_recordings': True, + }), + 'firmware_version': '1.4.26', + 'id': '**REDACTED**', + 'kind': 'lpd_v1', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'motion_snooze': None, + 'owned': True, + 'owner': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'id': '**REDACTED**', + 'last_name': '**REDACTED**', + }), + 'settings': dict({ + 'chime_settings': dict({ + 'duration': 3, + 'enable': True, + 'type': 0, + }), + 'doorbell_volume': 1, + 'enable_vod': True, + 'live_view_preset_profile': 'highest', + 'live_view_presets': list([ + 'low', + 'middle', + 'high', + 'highest', + ]), + 'motion_announcement': False, + 'motion_snooze_preset_profile': 'low', + 'motion_snooze_presets': list([ + 'null', + 'low', + 'medium', + 'high', + ]), + }), + 'subscribed': True, + 'subscribed_motions': True, + 'time_zone': 'America/New_York', + }), + dict({ + 'address': '**REDACTED**', + 'alerts': dict({ + 'connection': 'online', + }), + 'battery_life': 80, + 'description': '**REDACTED**', + 'device_id': '**REDACTED**', + 'external_connection': False, + 'features': dict({ + 'advanced_motion_enabled': False, + 'motion_message_enabled': False, + 'motions_enabled': True, + 'night_vision_enabled': False, + 'people_only_enabled': False, + 'shadow_correction_enabled': False, + 'show_recordings': True, + }), + 'firmware_version': '1.9.3', + 'id': '**REDACTED**', + 'kind': 'hp_cam_v1', + 'latitude': '**REDACTED**', + 'led_status': 'off', + 'location_id': None, + 'longitude': '**REDACTED**', + 'motion_snooze': dict({ + 'scheduled': True, + }), + 'night_mode_status': 'false', + 'owned': True, + 'owner': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'id': '**REDACTED**', + 'last_name': '**REDACTED**', + }), + 'ring_cam_light_installed': 'false', + 'ring_id': None, + 'settings': dict({ + 'chime_settings': dict({ + 'duration': 10, + 'enable': True, + 'type': 0, + }), + 'doorbell_volume': 11, + 'enable_vod': True, + 'floodlight_settings': dict({ + 'duration': 30, + 'priority': 0, + }), + 'light_schedule_settings': dict({ + 'end_hour': 0, + 'end_minute': 0, + 'start_hour': 0, + 'start_minute': 0, + }), + 'live_view_preset_profile': 'highest', + 'live_view_presets': list([ + 'low', + 'middle', + 'high', + 'highest', + ]), + 'motion_announcement': False, + 'motion_snooze_preset_profile': 'low', + 'motion_snooze_presets': list([ + 'none', + 'low', + 'medium', + 'high', + ]), + 'motion_zones': dict({ + 'active_motion_filter': 1, + 'advanced_object_settings': dict({ + 'human_detection_confidence': dict({ + 'day': 0.7, + 'night': 0.7, + }), + 'motion_zone_overlap': dict({ + 'day': 0.1, + 'night': 0.2, + }), + 'object_size_maximum': dict({ + 'day': 0.8, + 'night': 0.8, + }), + 'object_size_minimum': dict({ + 'day': 0.03, + 'night': 0.05, + }), + 'object_time_overlap': dict({ + 'day': 0.1, + 'night': 0.6, + }), + }), + 'enable_audio': False, + 'pir_settings': dict({ + 'sensitivity1': 1, + 'sensitivity2': 1, + 'sensitivity3': 1, + 'zone_mask': 6, + }), + 'sensitivity': 5, + 'zone1': dict({ + 'name': 'Zone 1', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + 'zone2': dict({ + 'name': 'Zone 2', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + 'zone3': dict({ + 'name': 'Zone 3', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + }), + 'pir_motion_zones': list([ + 0, + 1, + 1, + ]), + 'pir_settings': dict({ + 'sensitivity1': 1, + 'sensitivity2': 1, + 'sensitivity3': 1, + 'zone_mask': 6, + }), + 'stream_setting': 0, + 'video_settings': dict({ + 'ae_level': 0, + 'birton': None, + 'brightness': 0, + 'contrast': 64, + 'saturation': 80, + }), + }), + 'siren_status': dict({ + 'seconds_remaining': 0, + }), + 'stolen': False, + 'subscribed': True, + 'subscribed_motions': True, + 'time_zone': 'America/New_York', + }), + dict({ + 'address': '**REDACTED**', + 'alerts': dict({ + 'connection': 'online', + }), + 'battery_life': 80, + 'description': '**REDACTED**', + 'device_id': '**REDACTED**', + 'external_connection': False, + 'features': dict({ + 'advanced_motion_enabled': False, + 'motion_message_enabled': False, + 'motions_enabled': True, + 'night_vision_enabled': False, + 'people_only_enabled': False, + 'shadow_correction_enabled': False, + 'show_recordings': True, + }), + 'firmware_version': '1.9.3', + 'id': '**REDACTED**', + 'kind': 'hp_cam_v1', + 'latitude': '**REDACTED**', + 'led_status': 'on', + 'location_id': None, + 'longitude': '**REDACTED**', + 'motion_snooze': dict({ + 'scheduled': True, + }), + 'night_mode_status': 'false', + 'owned': True, + 'owner': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'id': '**REDACTED**', + 'last_name': '**REDACTED**', + }), + 'ring_cam_light_installed': 'false', + 'ring_id': None, + 'settings': dict({ + 'chime_settings': dict({ + 'duration': 10, + 'enable': True, + 'type': 0, + }), + 'doorbell_volume': 11, + 'enable_vod': True, + 'floodlight_settings': dict({ + 'duration': 30, + 'priority': 0, + }), + 'light_schedule_settings': dict({ + 'end_hour': 0, + 'end_minute': 0, + 'start_hour': 0, + 'start_minute': 0, + }), + 'live_view_preset_profile': 'highest', + 'live_view_presets': list([ + 'low', + 'middle', + 'high', + 'highest', + ]), + 'motion_announcement': False, + 'motion_snooze_preset_profile': 'low', + 'motion_snooze_presets': list([ + 'none', + 'low', + 'medium', + 'high', + ]), + 'motion_zones': dict({ + 'active_motion_filter': 1, + 'advanced_object_settings': dict({ + 'human_detection_confidence': dict({ + 'day': 0.7, + 'night': 0.7, + }), + 'motion_zone_overlap': dict({ + 'day': 0.1, + 'night': 0.2, + }), + 'object_size_maximum': dict({ + 'day': 0.8, + 'night': 0.8, + }), + 'object_size_minimum': dict({ + 'day': 0.03, + 'night': 0.05, + }), + 'object_time_overlap': dict({ + 'day': 0.1, + 'night': 0.6, + }), + }), + 'enable_audio': False, + 'pir_settings': dict({ + 'sensitivity1': 1, + 'sensitivity2': 1, + 'sensitivity3': 1, + 'zone_mask': 6, + }), + 'sensitivity': 5, + 'zone1': dict({ + 'name': 'Zone 1', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + 'zone2': dict({ + 'name': 'Zone 2', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + 'zone3': dict({ + 'name': 'Zone 3', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + }), + 'pir_motion_zones': list([ + 0, + 1, + 1, + ]), + 'pir_settings': dict({ + 'sensitivity1': 1, + 'sensitivity2': 1, + 'sensitivity3': 1, + 'zone_mask': 6, + }), + 'stream_setting': 0, + 'video_settings': dict({ + 'ae_level': 0, + 'birton': None, + 'brightness': 0, + 'contrast': 64, + 'saturation': 80, + }), + }), + 'siren_status': dict({ + 'seconds_remaining': 30, + }), + 'stolen': False, + 'subscribed': True, + 'subscribed_motions': True, + 'time_zone': 'America/New_York', + }), + ]), + }) +# --- diff --git a/tests/components/ring/test_diagnostics.py b/tests/components/ring/test_diagnostics.py new file mode 100644 index 00000000000..269446c3ad5 --- /dev/null +++ b/tests/components/ring/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Test Ring diagnostics.""" + +import requests_mock +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + requests_mock: requests_mock.Mocker, + snapshot: SnapshotAssertion, +) -> None: + """Test Ring diagnostics.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + assert diag == snapshot From cf985a870204208f2901a41444303bb87a4c9101 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 16 Nov 2023 14:43:02 +0100 Subject: [PATCH 531/982] Fix mock typing for Discovergy (#104047) --- tests/components/discovergy/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py index b3a452e36e5..2409c30bc6c 100644 --- a/tests/components/discovergy/conftest.py +++ b/tests/components/discovergy/conftest.py @@ -1,4 +1,5 @@ """Fixtures for Discovergy integration tests.""" +from collections.abc import Generator from unittest.mock import AsyncMock, patch from pydiscovergy import Discovergy @@ -24,7 +25,7 @@ def _meter_last_reading(meter_id: str) -> Reading: @pytest.fixture(name="discovergy") -def mock_discovergy() -> None: +def mock_discovergy() -> Generator[AsyncMock, None, None]: """Mock the pydiscovergy client.""" mock = AsyncMock(spec=Discovergy) mock.meters.return_value = GET_METERS From b400b33b0df579d27cbbe5a4d4c40bdb5440e673 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Nov 2023 15:28:48 +0100 Subject: [PATCH 532/982] Refer to domain configuration in custom validator errors (#104065) --- homeassistant/config.py | 16 ++++++++++++---- homeassistant/helpers/check_config.py | 2 +- tests/helpers/test_check_config.py | 2 +- tests/snapshots/test_config.ambr | 14 +++++++------- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index a33acead870..027839ca656 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -515,7 +515,7 @@ def async_log_config_validator_error( if hass is not None: async_notify_setup_error(hass, domain, link) - message = format_homeassistant_error(ex, domain, config, link) + message = format_homeassistant_error(hass, ex, domain, config, link) _LOGGER.error(message, exc_info=ex) @@ -677,11 +677,19 @@ def humanize_error( @callback def format_homeassistant_error( - ex: HomeAssistantError, domain: str, config: dict, link: str | None = None + hass: HomeAssistant, + ex: HomeAssistantError, + domain: str, + config: dict, + link: str | None = None, ) -> str: """Format HomeAssistantError thrown by a custom config validator.""" - message = f"Invalid config for [{domain}]: {str(ex) or repr(ex)}" - + message_prefix = f"Invalid config for [{domain}]" + # HomeAssistantError raised by custom config validator has no path to the + # offending configuration key, use the domain key as path instead. + if annotation := find_annotation(config, [domain]): + message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + message = f"{message_prefix}: {str(ex) or repr(ex)}" if domain != CONF_CORE and link: message += f" Please check the docs at {link}." diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 7466ddc6179..09bdf10cf70 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -115,7 +115,7 @@ async def async_check_ha_config_file( # noqa: C901 if isinstance(ex, vol.Invalid): message = format_schema_error(hass, ex, domain, component_config) else: - message = format_homeassistant_error(ex, domain, component_config) + message = format_homeassistant_error(hass, ex, domain, component_config) if domain in frontend_dependencies: result.add_error(message, domain, config_to_attach) else: diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 82500cb0b30..56df99bb032 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -453,7 +453,7 @@ action: HomeAssistantError("Broken"), 0, 1, - "Invalid config for [bla]: Broken", + "Invalid config for [bla] at configuration.yaml, line 11: Broken", ), ], ) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 97faac40bf5..b65617cb649 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -47,7 +47,7 @@ }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1]: broken', + 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 55: broken', }), dict({ 'has_exc_info': True, @@ -103,7 +103,7 @@ }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1]: broken', + 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 9: broken', }), dict({ 'has_exc_info': True, @@ -135,7 +135,7 @@ }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1]: broken', + 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 1: broken', }), dict({ 'has_exc_info': True, @@ -167,7 +167,7 @@ }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1]: broken', + 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 1: broken', }), dict({ 'has_exc_info': True, @@ -223,7 +223,7 @@ }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1]: broken', + 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 67: broken', }), dict({ 'has_exc_info': True, @@ -279,7 +279,7 @@ }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1]: broken', + 'message': 'Invalid config for [custom_validator_bad_1] at integrations/custom_validator_bad_1.yaml, line 2: broken', }), dict({ 'has_exc_info': True, @@ -306,7 +306,7 @@ Invalid config for [adr_0007_5] at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. ''', "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 52: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/custom_validator_ok_2.", - 'Invalid config for [custom_validator_bad_1]: broken Please check the docs at https://www.home-assistant.io/integrations/custom_validator_bad_1.', + 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 55: broken Please check the docs at https://www.home-assistant.io/integrations/custom_validator_bad_1.', 'Unknown error calling custom_validator_bad_2 config validator', ]) # --- From 2c003d8c105bb044d6847f03598b64ca2ffcac8c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Nov 2023 16:05:29 +0100 Subject: [PATCH 533/982] Remove Deconz entity descriptions required fields mixins (#104009) --- .../components/deconz/binary_sensor.py | 17 ++++------------- homeassistant/components/deconz/button.py | 15 +++++---------- homeassistant/components/deconz/number.py | 11 +++-------- homeassistant/components/deconz/sensor.py | 16 +++++----------- 4 files changed, 17 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 114e401346d..84141eac964 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -65,24 +65,15 @@ T = TypeVar( ) -@dataclass -class DeconzBinarySensorDescriptionMixin(Generic[T]): - """Required values when describing secondary sensor attributes.""" - - update_key: str - value_fn: Callable[[T], bool | None] - - -@dataclass -class DeconzBinarySensorDescription( - BinarySensorEntityDescription, - DeconzBinarySensorDescriptionMixin[T], -): +@dataclass(kw_only=True) +class DeconzBinarySensorDescription(Generic[T], BinarySensorEntityDescription): """Class describing deCONZ binary sensor entities.""" instance_check: type[T] | None = None name_suffix: str = "" old_unique_id_suffix: str = "" + update_key: str + value_fn: Callable[[T], bool | None] ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = ( diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index 318e0e43beb..81d839ea0f2 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -23,18 +23,13 @@ from .deconz_device import DeconzDevice, DeconzSceneMixin from .gateway import DeconzGateway, get_gateway_from_config_entry -@dataclass -class DeconzButtonDescriptionMixin: - """Required values when describing deCONZ button entities.""" - - suffix: str - button_fn: str - - -@dataclass -class DeconzButtonDescription(ButtonEntityDescription, DeconzButtonDescriptionMixin): +@dataclass(kw_only=True) +class DeconzButtonDescription(ButtonEntityDescription): """Class describing deCONZ button entities.""" + button_fn: str + suffix: str + ENTITY_DESCRIPTIONS = { PydeconzScene: [ diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index ec4438502b6..7cc0da936cb 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -31,9 +31,9 @@ from .util import serial_from_unique_id T = TypeVar("T", Presence, PydeconzSensorBase) -@dataclass -class DeconzNumberDescriptionMixin(Generic[T]): - """Required values when describing deCONZ number entities.""" +@dataclass(kw_only=True) +class DeconzNumberDescription(Generic[T], NumberEntityDescription): + """Class describing deCONZ number entities.""" instance_check: type[T] name_suffix: str @@ -42,11 +42,6 @@ class DeconzNumberDescriptionMixin(Generic[T]): value_fn: Callable[[T], float | None] -@dataclass -class DeconzNumberDescription(NumberEntityDescription, DeconzNumberDescriptionMixin[T]): - """Class describing deCONZ number entities.""" - - ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = ( DeconzNumberDescription[Presence]( key="delay", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 4e00ac0a415..a635a784676 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -91,22 +91,16 @@ T = TypeVar( ) -@dataclass -class DeconzSensorDescriptionMixin(Generic[T]): - """Required values when describing secondary sensor attributes.""" - - supported_fn: Callable[[T], bool] - update_key: str - value_fn: Callable[[T], datetime | StateType] - - -@dataclass -class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMixin[T]): +@dataclass(kw_only=True) +class DeconzSensorDescription(Generic[T], SensorEntityDescription): """Class describing deCONZ binary sensor entities.""" instance_check: type[T] | None = None name_suffix: str = "" old_unique_id_suffix: str = "" + supported_fn: Callable[[T], bool] + update_key: str + value_fn: Callable[[T], datetime | StateType] ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( From 7c030cfffaa56b975a491eafae072b6d3634ea08 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 16 Nov 2023 16:13:15 +0100 Subject: [PATCH 534/982] Add tests for Discovergy to reach full test coverage (#104039) * Add tests for Discovergy to reach full test coverage * Use snapshots and freezer --- .coveragerc | 3 - .../discovergy/snapshots/test_sensor.ambr | 154 ++++++++++++++++++ tests/components/discovergy/test_init.py | 62 +++++++ tests/components/discovergy/test_sensor.py | 71 ++++++++ 4 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 tests/components/discovergy/snapshots/test_sensor.ambr create mode 100644 tests/components/discovergy/test_init.py create mode 100644 tests/components/discovergy/test_sensor.py diff --git a/.coveragerc b/.coveragerc index d6809f4301d..13de6cb29c8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -218,9 +218,6 @@ omit = homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py homeassistant/components/discord/notify.py - homeassistant/components/discovergy/__init__.py - homeassistant/components/discovergy/sensor.py - homeassistant/components/discovergy/coordinator.py homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/data.py diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..36af1276fe1 --- /dev/null +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -0,0 +1,154 @@ +# serializer version: 1 +# name: test_sensor[electricity total consumption] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_teststrasse_1_total_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 4, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total consumption', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_consumption', + 'unique_id': 'abc123-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[electricity total consumption].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Electricity Teststraße 1 Total consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.electricity_teststrasse_1_total_consumption', + 'last_changed': , + 'last_updated': , + 'state': '11934.8699715', + }) +# --- +# name: test_sensor[electricity total power] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_teststrasse_1_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'abc123-power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[electricity total power].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Electricity Teststraße 1 Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.electricity_teststrasse_1_total_power', + 'last_changed': , + 'last_updated': , + 'state': '531.75', + }) +# --- +# name: test_sensor[gas total consumption] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_teststrasse_1_total_gas_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 4, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total gas consumption', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_consumption', + 'unique_id': 'def456-volume', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[gas total consumption].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas Teststraße 1 Total gas consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_teststrasse_1_total_gas_consumption', + 'last_changed': , + 'last_updated': , + 'state': '21064.8', + }) +# --- diff --git a/tests/components/discovergy/test_init.py b/tests/components/discovergy/test_init.py new file mode 100644 index 00000000000..ac8f79540f5 --- /dev/null +++ b/tests/components/discovergy/test_init.py @@ -0,0 +1,62 @@ +"""Test Discovergy component setup.""" +from unittest.mock import AsyncMock + +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("discovergy") +async def test_config_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test for setup success.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("error", "expected_state"), + [ + (InvalidLogin, ConfigEntryState.SETUP_ERROR), + (HTTPError, ConfigEntryState.SETUP_RETRY), + (DiscovergyClientError, ConfigEntryState.SETUP_RETRY), + (Exception, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_config_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + discovergy: AsyncMock, + error: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test for setup failure.""" + config_entry.add_to_hass(hass) + + discovergy.meters.side_effect = error + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is expected_state + + +@pytest.mark.usefixtures("setup_integration") +async def test_reload_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test config entry reload.""" + new_data = {"email": "abc@example.com", "password": "password"} + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.config_entries.async_update_entry(config_entry, data=new_data) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data == new_data diff --git a/tests/components/discovergy/test_sensor.py b/tests/components/discovergy/test_sensor.py new file mode 100644 index 00000000000..33fb1a37cd9 --- /dev/null +++ b/tests/components/discovergy/test_sensor.py @@ -0,0 +1,71 @@ +"""Tests Discovergy sensor component.""" +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +@pytest.mark.parametrize( + "state_name", + [ + "sensor.electricity_teststrasse_1_total_consumption", + "sensor.electricity_teststrasse_1_total_power", + "sensor.gas_teststrasse_1_total_gas_consumption", + ], + ids=[ + "electricity total consumption", + "electricity total power", + "gas total consumption", + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + state_name: str, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor setup and update.""" + + entry = entity_registry.async_get(state_name) + assert entry == snapshot + + state = hass.states.get(state_name) + assert state == snapshot + + +@pytest.mark.parametrize( + "error", + [ + InvalidLogin, + HTTPError, + DiscovergyClientError, + Exception, + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_sensor_update_fail( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + discovergy: AsyncMock, + error: Exception, +) -> None: + """Test sensor errors.""" + state = hass.states.get("sensor.electricity_teststrasse_1_total_consumption") + assert state + assert state.state == "11934.8699715" + + discovergy.meter_last_reading.side_effect = error + + freezer.tick(timedelta(minutes=1)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.electricity_teststrasse_1_total_consumption") + assert state + assert state.state == "unavailable" From 4536fb3541047b4919d44339355cb51ac6fc94db Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Nov 2023 16:55:08 +0100 Subject: [PATCH 535/982] Remove mock_entity_platform test helper (#104073) --- tests/common.py | 12 -- .../components/assist_pipeline/test_select.py | 4 +- .../components/config/test_config_entries.py | 32 ++-- tests/components/hassio/test_discovery.py | 9 +- tests/components/mqtt/test_discovery.py | 6 +- tests/helpers/test_config_entry_flow.py | 21 +-- tests/helpers/test_discovery.py | 9 +- tests/helpers/test_entity_component.py | 30 ++-- tests/helpers/test_entity_platform.py | 28 +-- tests/helpers/test_reload.py | 18 +- tests/helpers/test_restore_state.py | 6 +- .../helpers/test_schema_config_entry_flow.py | 16 +- tests/test_bootstrap.py | 4 +- tests/test_config_entries.py | 164 +++++++++--------- tests/test_setup.py | 26 +-- 15 files changed, 176 insertions(+), 209 deletions(-) diff --git a/tests/common.py b/tests/common.py index 1737eae21e6..bc770fae2fe 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1339,18 +1339,6 @@ def mock_integration( return integration -def mock_entity_platform( - hass: HomeAssistant, platform_path: str, module: MockPlatform | None -) -> None: - """Mock a entity platform. - - platform_path is in form light.hue. Will create platform - hue.light. - """ - domain, platform_name = platform_path.split(".") - mock_platform(hass, f"{platform_name}.{domain}", module) - - def mock_platform( hass: HomeAssistant, platform_path: str, module: Mock | MockPlatform | None = None ) -> None: diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 9e70e65e0a8..c4e750e1019 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -20,7 +20,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from tests.common import MockConfigEntry, MockPlatform, mock_entity_platform +from tests.common import MockConfigEntry, MockPlatform, mock_platform class SelectPlatform(MockPlatform): @@ -47,7 +47,7 @@ class SelectPlatform(MockPlatform): @pytest.fixture async def init_select(hass: HomeAssistant, init_components) -> ConfigEntry: """Initialize select entity.""" - mock_entity_platform(hass, "select.assist_pipeline", SelectPlatform()) + mock_platform(hass, "assist_pipeline.select", SelectPlatform()) config_entry = MockConfigEntry(domain="assist_pipeline") config_entry.add_to_hass(hass) assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 3cc7ada49ba..bfee7551cff 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -19,8 +19,8 @@ from tests.common import ( MockConfigEntry, MockModule, MockUser, - mock_entity_platform, mock_integration, + mock_platform, ) from tests.typing import WebSocketGenerator @@ -304,7 +304,7 @@ async def test_reload_entry_in_setup_retry( async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) entry = MockConfigEntry(domain="comp", state=core_ce.ConfigEntryState.SETUP_RETRY) entry.supports_unload = True entry.add_to_hass(hass) @@ -353,7 +353,7 @@ async def test_available_flows( async def test_initialize_flow(hass: HomeAssistant, client) -> None: """Test we can initialize a flow.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): @@ -402,7 +402,7 @@ async def test_initialize_flow(hass: HomeAssistant, client) -> None: async def test_initialize_flow_unmet_dependency(hass: HomeAssistant, client) -> None: """Test unmet dependencies are listed.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_schema = vol.Schema({"comp_conf": {"hello": str}}, required=True) mock_integration( @@ -458,7 +458,7 @@ async def test_initialize_flow_unauth( async def test_abort(hass: HomeAssistant, client) -> None: """Test a flow that aborts.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): @@ -484,7 +484,7 @@ async def test_create_account( hass: HomeAssistant, client, enable_custom_integrations: None ) -> None: """Test a flow that creates an account.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) @@ -542,7 +542,7 @@ async def test_two_step_flow( mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): VERSION = 1 @@ -619,7 +619,7 @@ async def test_continue_flow_unauth( mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): VERSION = 1 @@ -666,7 +666,7 @@ async def test_get_progress_index( ) -> None: """Test querying for the flows that are in progress.""" assert await async_setup_component(hass, "config", {}) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) class TestFlow(core_ce.ConfigFlow): @@ -714,7 +714,7 @@ async def test_get_progress_index_unauth( async def test_get_progress_flow(hass: HomeAssistant, client) -> None: """Test we can query the API for same result as we get from init a flow.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): @@ -750,7 +750,7 @@ async def test_get_progress_flow_unauth( hass: HomeAssistant, client, hass_admin_user: MockUser ) -> None: """Test we can can't query the API for result of flow.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): @@ -804,7 +804,7 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: return OptionsFlowHandler() mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) MockConfigEntry( domain="test", entry_id="test1", @@ -862,7 +862,7 @@ async def test_options_flow_unauth( return OptionsFlowHandler() mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) MockConfigEntry( domain="test", entry_id="test1", @@ -883,7 +883,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): @staticmethod @@ -950,7 +950,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): @staticmethod @@ -1265,7 +1265,7 @@ async def test_ignore_flow( mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): VERSION = 1 diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 5c4717fd561..0923967a480 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -12,12 +12,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import ( - MockModule, - mock_config_flow, - mock_entity_platform, - mock_integration, -) +from tests.common import MockModule, mock_config_flow, mock_integration, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker @@ -25,7 +20,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def mock_mqtt_fixture(hass): """Mock the MQTT integration's config flow.""" mock_integration(hass, MockModule(MQTT_DOMAIN)) - mock_entity_platform(hass, f"config_flow.{MQTT_DOMAIN}", None) + mock_platform(hass, f"{MQTT_DOMAIN}.config_flow", None) class MqttFlow(config_entries.ConfigFlow): """Test flow.""" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index ed01b70e660..50809a11fc1 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -34,7 +34,7 @@ from tests.common import ( MockConfigEntry, async_capture_events, async_fire_mqtt_message, - mock_entity_platform, + mock_platform, ) from tests.typing import ( MqttMockHAClientGenerator, @@ -1499,7 +1499,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( ) -> None: """Check MQTT integration discovery subscribe and unsubscribe.""" mqtt_mock = await mqtt_mock_entry() - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) entry = hass.config_entries.async_entries("mqtt")[0] mqtt_mock().connected = True @@ -1552,7 +1552,7 @@ async def test_mqtt_discovery_unsubscribe_once( ) -> None: """Check MQTT integration discovery unsubscribe once.""" mqtt_mock = await mqtt_mock_entry() - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) entry = hass.config_entries.async_entries("mqtt")[0] mqtt_mock().connected = True diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 90d8030be79..71c81b096ca 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -9,12 +9,7 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow -from tests.common import ( - MockConfigEntry, - MockModule, - mock_entity_platform, - mock_integration, -) +from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform @pytest.fixture @@ -77,7 +72,7 @@ async def test_user_has_confirmation( ) -> None: """Test user requires confirmation to setup.""" discovery_flow_conf["discovered"] = True - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) result = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_USER}, data={} @@ -184,7 +179,7 @@ async def test_multiple_discoveries( hass: HomeAssistant, discovery_flow_conf: dict[str, bool] ) -> None: """Test we only create one instance for multiple discoveries.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) result = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY}, data={} @@ -202,7 +197,7 @@ async def test_only_one_in_progress( hass: HomeAssistant, discovery_flow_conf: dict[str, bool] ) -> None: """Test a user initialized one will finish and cancel discovered one.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) # Discovery starts flow result = await hass.config_entries.flow.async_init( @@ -230,7 +225,7 @@ async def test_import_abort_discovery( hass: HomeAssistant, discovery_flow_conf: dict[str, bool] ) -> None: """Test import will finish and cancel discovered one.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) # Discovery starts flow result = await hass.config_entries.flow.async_init( @@ -280,7 +275,7 @@ async def test_ignored_discoveries( hass: HomeAssistant, discovery_flow_conf: dict[str, bool] ) -> None: """Test we can ignore discovered entries.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) result = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY}, data={} @@ -373,7 +368,7 @@ async def test_webhook_create_cloudhook( async_remove_entry=config_entry_flow.webhook_async_remove_entry, ), ) - mock_entity_platform(hass, "config_flow.test_single", None) + mock_platform(hass, "test_single.config_flow", None) result = await hass.config_entries.flow.async_init( "test_single", context={"source": config_entries.SOURCE_USER} @@ -428,7 +423,7 @@ async def test_webhook_create_cloudhook_aborts_not_connected( async_remove_entry=config_entry_flow.webhook_async_remove_entry, ), ) - mock_entity_platform(hass, "config_flow.test_single", None) + mock_platform(hass, "test_single.config_flow", None) result = await hass.config_entries.flow.async_init( "test_single", context={"source": config_entries.SOURCE_USER} diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index 2900cb2c09e..d73bfe84607 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -9,12 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery from homeassistant.helpers.dispatcher import async_dispatcher_send -from tests.common import ( - MockModule, - MockPlatform, - mock_entity_platform, - mock_integration, -) +from tests.common import MockModule, MockPlatform, mock_integration, mock_platform @pytest.fixture @@ -136,7 +131,7 @@ async def test_circular_import(hass: HomeAssistant) -> None: # dependencies are only set in component level # since we are using manifest to hold them mock_integration(hass, MockModule("test_circular", dependencies=["test_component"])) - mock_entity_platform(hass, "switch.test_circular", MockPlatform(setup_platform)) + mock_platform(hass, "test_circular.switch", MockPlatform(setup_platform)) await setup.async_setup_component( hass, diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index b5cda6770c5..40e25633992 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -35,8 +35,8 @@ from tests.common import ( MockModule, MockPlatform, async_fire_time_changed, - mock_entity_platform, mock_integration, + mock_platform, ) _LOGGER = logging.getLogger(__name__) @@ -51,7 +51,7 @@ async def test_setup_loads_platforms(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("test_component", setup=component_setup)) # mock the dependencies mock_integration(hass, MockModule("mod2", dependencies=["test_component"])) - mock_entity_platform(hass, "test_domain.mod2", MockPlatform(platform_setup)) + mock_platform(hass, "mod2.test_domain", MockPlatform(platform_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -70,8 +70,8 @@ async def test_setup_recovers_when_setup_raises(hass: HomeAssistant) -> None: platform1_setup = Mock(side_effect=Exception("Broken")) platform2_setup = Mock(return_value=None) - mock_entity_platform(hass, "test_domain.mod1", MockPlatform(platform1_setup)) - mock_entity_platform(hass, "test_domain.mod2", MockPlatform(platform2_setup)) + mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) + mock_platform(hass, "mod2.test_domain", MockPlatform(platform2_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -130,7 +130,7 @@ async def test_set_scan_interval_via_config( """Test the platform setup.""" add_entities([MockEntity(should_poll=True)]) - mock_entity_platform(hass, "test_domain.platform", MockPlatform(platform_setup)) + mock_platform(hass, "platform.test_domain", MockPlatform(platform_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -157,7 +157,7 @@ async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None: platform = MockPlatform(platform_setup) - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -205,7 +205,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: """Test that we retry when platform not ready.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) mock_integration(hass, MockModule("mod1")) - mock_entity_platform(hass, "test_domain.mod1", MockPlatform(platform1_setup)) + mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -309,7 +309,7 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: hass, MockModule("test_component", dependencies=["test_component2"]) ) mock_integration(hass, MockModule("test_component2")) - mock_entity_platform(hass, "test_domain.test_component", MockPlatform()) + mock_platform(hass, "test_component.test_domain", MockPlatform()) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -323,9 +323,9 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup entry calls async_setup_entry on platform.""" mock_setup_entry = AsyncMock(return_value=True) - mock_entity_platform( + mock_platform( hass, - "test_domain.entry_domain", + "entry_domain.test_domain", MockPlatform( async_setup_entry=mock_setup_entry, scan_interval=timedelta(seconds=5) ), @@ -354,9 +354,9 @@ async def test_setup_entry_platform_not_exist(hass: HomeAssistant) -> None: async def test_setup_entry_fails_duplicate(hass: HomeAssistant) -> None: """Test we don't allow setting up a config entry twice.""" mock_setup_entry = AsyncMock(return_value=True) - mock_entity_platform( + mock_platform( hass, - "test_domain.entry_domain", + "entry_domain.test_domain", MockPlatform(async_setup_entry=mock_setup_entry), ) @@ -372,9 +372,9 @@ async def test_setup_entry_fails_duplicate(hass: HomeAssistant) -> None: async def test_unload_entry_resets_platform(hass: HomeAssistant) -> None: """Test unloading an entry removes all entities.""" mock_setup_entry = AsyncMock(return_value=True) - mock_entity_platform( + mock_platform( hass, - "test_domain.entry_domain", + "entry_domain.test_domain", MockPlatform(async_setup_entry=mock_setup_entry), ) @@ -673,7 +673,7 @@ async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: """Test that we shutdown platforms on stop.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) mock_integration(hass, MockModule("mod1")) - mock_entity_platform(hass, "test_domain.mod1", MockPlatform(platform1_setup)) + mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 57020268323..721114c1a7b 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -43,7 +43,7 @@ from tests.common import ( MockEntityPlatform, MockPlatform, async_fire_time_changed, - mock_entity_platform, + mock_platform, mock_registry, ) @@ -195,7 +195,7 @@ async def test_set_scan_interval_via_platform( platform = MockPlatform(platform_setup) platform.SCAN_INTERVAL = timedelta(seconds=30) - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -230,7 +230,7 @@ async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None: """Warn we log when platform setup takes a long time.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -264,7 +264,7 @@ async def test_platform_error_slow_setup( platform = MockPlatform(async_setup_platform=setup_platform) component = EntityComponent(_LOGGER, DOMAIN, hass) - mock_entity_platform(hass, "test_domain.test_platform", platform) + mock_platform(hass, "test_platform.test_domain", platform) await component.async_setup({DOMAIN: {"platform": "test_platform"}}) await hass.async_block_till_done() assert len(called) == 1 @@ -298,7 +298,7 @@ async def test_parallel_updates_async_platform(hass: HomeAssistant) -> None: """Test async platform does not have parallel_updates limit by default.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -328,7 +328,7 @@ async def test_parallel_updates_async_platform_with_constant( platform = MockPlatform() platform.PARALLEL_UPDATES = 2 - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -355,7 +355,7 @@ async def test_parallel_updates_sync_platform(hass: HomeAssistant) -> None: """Test sync platform parallel_updates default set to 1.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -381,7 +381,7 @@ async def test_parallel_updates_no_update_method(hass: HomeAssistant) -> None: """Test platform parallel_updates default set to 0.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -403,7 +403,7 @@ async def test_parallel_updates_sync_platform_with_constant( platform = MockPlatform() platform.PARALLEL_UPDATES = 2 - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -431,7 +431,7 @@ async def test_parallel_updates_async_platform_updates_in_parallel( """Test an async platform is updated in parallel.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.async_platform", platform) + mock_platform(hass, "async_platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -479,7 +479,7 @@ async def test_parallel_updates_sync_platform_updates_in_sequence( """Test a sync platform is updated in sequence.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -1660,16 +1660,16 @@ async def test_setup_entry_with_entities_that_block_forever( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") - mock_entity_platform = MockEntityPlatform( + platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) with patch.object(entity_platform, "SLOW_ADD_ENTITY_MAX_WAIT", 0.01), patch.object( entity_platform, "SLOW_ADD_MIN_TIMEOUT", 0.01 ): - assert await mock_entity_platform.async_setup_entry(config_entry) + assert await platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{mock_entity_platform.domain}.{config_entry.domain}" + full_name = f"{platform.domain}.{config_entry.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 assert len(entity_registry.entities) == 1 diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index ad3b7ccb243..9c3789a3553 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -21,8 +21,8 @@ from tests.common import ( MockModule, MockPlatform, get_fixture_path, - mock_entity_platform, mock_integration, + mock_platform, ) _LOGGER = logging.getLogger(__name__) @@ -42,8 +42,8 @@ async def test_reload_platform(hass: HomeAssistant) -> None: mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -82,8 +82,8 @@ async def test_setup_reload_service(hass: HomeAssistant) -> None: mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -123,8 +123,8 @@ async def test_setup_reload_service_when_async_process_component_config_fails( mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -173,8 +173,8 @@ async def test_setup_reload_service_with_platform_that_provides_async_reset_plat mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index fa0a14b8fbb..f01718d6af6 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -31,8 +31,8 @@ from tests.common import ( MockModule, MockPlatform, async_fire_time_changed, - mock_entity_platform, mock_integration, + mock_platform, ) _LOGGER = logging.getLogger(__name__) @@ -499,8 +499,8 @@ async def test_restore_entity_end_to_end( mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=async_setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=async_setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index b069f0cb8f5..58f6a261aef 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -23,13 +23,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from homeassistant.util.decorator import Registry -from tests.common import ( - MockConfigEntry, - MockModule, - mock_entity_platform, - mock_integration, - mock_platform, -) +from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform TEST_DOMAIN = "test" @@ -232,7 +226,7 @@ async def test_options_flow_advanced_option( options_flow = OPTIONS_FLOW mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -521,7 +515,7 @@ async def test_suggested_values( options_flow = OPTIONS_FLOW mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -634,7 +628,7 @@ async def test_options_flow_state(hass: HomeAssistant) -> None: options_flow = OPTIONS_FLOW mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -700,7 +694,7 @@ async def test_options_flow_omit_optional_keys( options_flow = OPTIONS_FLOW mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_entry = MockConfigEntry( data={}, domain="test", diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 555bcbdf6b2..f5e01e0c97b 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -23,8 +23,8 @@ from .common import ( MockModule, MockPlatform, get_test_config_dir, - mock_entity_platform, mock_integration, + mock_platform, ) VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) @@ -327,7 +327,7 @@ async def test_setup_after_deps_via_platform(hass: HomeAssistant) -> None: partial_manifest={"after_dependencies": ["after_dep_of_platform_int"]}, ), ) - mock_entity_platform(hass, "light.platform_int", MockPlatform()) + mock_platform(hass, "platform_int.light", MockPlatform()) @callback def continue_loading(_): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index a3c052971e3..f63972c79e8 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -40,8 +40,8 @@ from .common import ( MockPlatform, async_fire_time_changed, mock_config_flow, - mock_entity_platform, mock_integration, + mock_platform, ) from tests.common import async_get_persistent_notifications @@ -92,7 +92,7 @@ async def test_call_setup_entry(hass: HomeAssistant) -> None: async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) with patch("homeassistant.config_entries.support_entry_unload", return_value=True): result = await async_setup_component(hass, "comp", {}) @@ -121,7 +121,7 @@ async def test_call_setup_entry_without_reload_support(hass: HomeAssistant) -> N async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) with patch("homeassistant.config_entries.support_entry_unload", return_value=False): result = await async_setup_component(hass, "comp", {}) @@ -151,7 +151,7 @@ async def test_call_async_migrate_entry(hass: HomeAssistant) -> None: async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) with patch("homeassistant.config_entries.support_entry_unload", return_value=True): result = await async_setup_component(hass, "comp", {}) @@ -181,7 +181,7 @@ async def test_call_async_migrate_entry_failure_false(hass: HomeAssistant) -> No async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) result = await async_setup_component(hass, "comp", {}) assert result @@ -209,7 +209,7 @@ async def test_call_async_migrate_entry_failure_exception(hass: HomeAssistant) - async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) result = await async_setup_component(hass, "comp", {}) assert result @@ -237,7 +237,7 @@ async def test_call_async_migrate_entry_failure_not_bool(hass: HomeAssistant) -> async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) result = await async_setup_component(hass, "comp", {}) assert result @@ -259,7 +259,7 @@ async def test_call_async_migrate_entry_failure_not_supported( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) result = await async_setup_component(hass, "comp", {}) assert result @@ -311,10 +311,10 @@ async def test_remove_entry( async_remove_entry=mock_remove_entry, ), ) - mock_entity_platform( - hass, "light.test", MockPlatform(async_setup_entry=mock_setup_entry_platform) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) MockConfigEntry(domain="test_other", entry_id="test1").add_to_manager(manager) entry = MockConfigEntry(domain="test", entry_id="test2") @@ -371,7 +371,7 @@ async def test_remove_entry_cancels_reauth( mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry.add_to_hass(hass) await entry.async_setup(hass) @@ -510,7 +510,7 @@ async def test_add_entry_calls_setup_entry( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -610,7 +610,7 @@ async def test_saving_and_loading(hass: HomeAssistant) -> None: "test", async_setup_entry=lambda *args: AsyncMock(return_value=True) ), ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -721,7 +721,7 @@ async def test_discovery_notification( ) -> None: """Test that we create/dismiss a notification when source is discovery.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch.dict(config_entries.HANDLERS): @@ -775,7 +775,7 @@ async def test_discovery_notification( async def test_reauth_notification(hass: HomeAssistant) -> None: """Test that we create/dismiss a notification when source is reauth.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch.dict(config_entries.HANDLERS): @@ -842,7 +842,7 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: """Test that we not create a notification when discovery is aborted.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -940,7 +940,7 @@ async def test_setup_raise_not_ready( side_effect=ConfigEntryNotReady("The internet connection is offline") ) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) @@ -978,7 +978,7 @@ async def test_setup_raise_not_ready_from_exception( mock_setup_entry = AsyncMock(side_effect=config_entry_exception) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) @@ -996,7 +996,7 @@ async def test_setup_retrying_during_unload(hass: HomeAssistant) -> None: mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) @@ -1018,7 +1018,7 @@ async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -1043,7 +1043,7 @@ async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) @@ -1081,7 +1081,7 @@ async def test_create_entry_options( "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1114,7 +1114,7 @@ async def test_entry_options( ) -> None: """Test that we can set options on an entry.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1152,7 +1152,7 @@ async def test_entry_options_abort( ) -> None: """Test that we can abort options flow.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1186,7 +1186,7 @@ async def test_entry_options_unknown_config_entry( ) -> None: """Test that we can abort options flow.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow: """Test flow.""" @@ -1218,7 +1218,7 @@ async def test_entry_setup_succeed( hass, MockModule("comp", async_setup=mock_setup, async_setup_entry=mock_setup_entry), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) assert await manager.async_setup(entry.entry_id) assert len(mock_setup.mock_calls) == 1 @@ -1350,7 +1350,7 @@ async def test_entry_reload_succeed( async_unload_entry=async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) assert await manager.async_reload(entry.entry_id) assert len(async_unload_entry.mock_calls) == 1 @@ -1389,7 +1389,7 @@ async def test_entry_reload_not_loaded( async_unload_entry=async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) assert await manager.async_reload(entry.entry_id) assert len(async_unload_entry.mock_calls) == 0 @@ -1458,7 +1458,7 @@ async def test_entry_disable_succeed( async_unload_entry=async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # Disable assert await manager.async_set_disabled_by( @@ -1495,7 +1495,7 @@ async def test_entry_disable_without_reload_support( async_setup_entry=async_setup_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # Disable assert not await manager.async_set_disabled_by( @@ -1536,7 +1536,7 @@ async def test_entry_enable_without_reload_support( async_setup_entry=async_setup_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # Enable assert await manager.async_set_disabled_by(entry.entry_id, None) @@ -1582,7 +1582,7 @@ async def test_init_custom_integration_with_missing_handler( hass, MockModule("hue"), ) - mock_entity_platform(hass, "config_flow.hue", None) + mock_platform(hass, "hue.config_flow", None) with pytest.raises(data_entry_flow.UnknownHandler), patch( "homeassistant.loader.async_get_integration", return_value=integration, @@ -1634,7 +1634,7 @@ async def test_reload_entry_entity_registry_works( async_unload_entry=mock_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # Only changing disabled_by should update trigger entity_entry = entity_registry.async_get_or_create( @@ -1676,7 +1676,7 @@ async def test_unique_id_persisted( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1724,7 +1724,7 @@ async def test_unique_id_existing_entry( async_remove_entry=async_remove_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1772,7 +1772,7 @@ async def test_entry_id_existing_entry( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1811,7 +1811,7 @@ async def test_unique_id_update_existing_entry_without_reload( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1857,7 +1857,7 @@ async def test_unique_id_update_existing_entry_with_reload( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) updates = {"host": "1.1.1.1"} class TestFlow(config_entries.ConfigFlow): @@ -1923,7 +1923,7 @@ async def test_unique_id_from_discovery_in_setup_retry( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1991,7 +1991,7 @@ async def test_unique_id_not_update_existing_entry( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2025,7 +2025,7 @@ async def test_unique_id_in_progress( ) -> None: """Test that we abort if there is already a flow in progress with same unique id.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2061,7 +2061,7 @@ async def test_finish_flow_aborts_progress( hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2100,7 +2100,7 @@ async def test_unique_id_ignore( """Test that we can ignore flows that are in progress and have a unique ID.""" async_setup_entry = AsyncMock(return_value=False) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2157,7 +2157,7 @@ async def test_manual_add_overrides_ignored_entry( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2204,7 +2204,7 @@ async def test_manual_add_overrides_ignored_entry_singleton( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2245,7 +2245,7 @@ async def test__async_current_entries_does_not_skip_ignore_non_user( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2282,7 +2282,7 @@ async def test__async_current_entries_explicit_skip_ignore( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2323,7 +2323,7 @@ async def test__async_current_entries_explicit_include_ignore( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2351,7 +2351,7 @@ async def test_unignore_step_form( """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2398,7 +2398,7 @@ async def test_unignore_create_entry( """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2448,7 +2448,7 @@ async def test_unignore_default_impl( """Test that resdicovery is a no-op by default.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2482,7 +2482,7 @@ async def test_partial_flows_hidden( """Test that flows that don't have a cur_step and haven't finished initing are hidden.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # A flag to test our assertion that `async_step_discovery` was called and is in its blocked state # This simulates if the step was e.g. doing network i/o @@ -2562,7 +2562,7 @@ async def test_async_setup_init_entry( "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2616,7 +2616,7 @@ async def test_async_setup_init_entry_completes_before_loaded_event_fires( "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2682,7 +2682,7 @@ async def test_async_setup_update_entry(hass: HomeAssistant) -> None: async_setup_entry=mock_async_setup_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2734,7 +2734,7 @@ async def test_flow_with_default_discovery( hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2781,7 +2781,7 @@ async def test_flow_with_default_discovery_with_unique_id( ) -> None: """Test discovery flow using the default discovery is ignored when unique ID is set.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2818,7 +2818,7 @@ async def test_default_discovery_abort_existing_entries( entry.add_to_hass(hass) mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2838,7 +2838,7 @@ async def test_default_discovery_in_progress( ) -> None: """Test that a flow using default discovery can only be triggered once.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2878,7 +2878,7 @@ async def test_default_discovery_abort_on_new_unique_flow( ) -> None: """Test that a flow using default discovery is aborted when a second flow with unique ID is created.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2920,7 +2920,7 @@ async def test_default_discovery_abort_on_user_flow_complete( ) -> None: """Test that a flow using default discovery is aborted when a second flow completes.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2977,7 +2977,7 @@ async def test_flow_same_device_multiple_sources( hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -3088,7 +3088,7 @@ async def test_entry_reload_calls_on_unload_listeners( async_unload_entry=async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) mock_unload_callback = Mock() @@ -3119,7 +3119,7 @@ async def test_setup_raise_entry_error( side_effect=ConfigEntryError("Incompatible firmware version") ) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3156,7 +3156,7 @@ async def test_setup_raise_entry_error_from_first_coordinator_update( return True mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3193,7 +3193,7 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update( return True mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3215,7 +3215,7 @@ async def test_setup_raise_auth_failed( side_effect=ConfigEntryAuthFailed("The password is no longer valid") ) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3267,7 +3267,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( return True mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3316,7 +3316,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( return True mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3361,7 +3361,7 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch("homeassistant.helpers.event.async_call_later") as mock_call: await entry.async_setup(hass) @@ -3444,7 +3444,7 @@ async def test__async_abort_entries_match( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -3530,7 +3530,7 @@ async def test__async_abort_entries_match_options_flow( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test_abort", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test_abort", None) + mock_platform(hass, "test_abort.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -3649,7 +3649,7 @@ async def test_entry_reload_concurrency( async_unload_entry=_async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) tasks = [] for _ in range(15): tasks.append(asyncio.create_task(manager.async_reload(entry.entry_id))) @@ -3689,7 +3689,7 @@ async def test_unique_id_update_while_setup_in_progress( async_unload_entry=mock_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) updates = {"host": "1.1.1.1"} hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) @@ -3752,7 +3752,7 @@ async def test_reauth(hass: HomeAssistant) -> None: mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3812,7 +3812,7 @@ async def test_get_active_flows(hass: HomeAssistant) -> None: entry = MockConfigEntry(title="test_title", domain="test") mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3845,7 +3845,7 @@ async def test_async_wait_component_dynamic(hass: HomeAssistant) -> None: mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry.add_to_hass(hass) @@ -3876,7 +3876,7 @@ async def test_async_wait_component_startup(hass: HomeAssistant) -> None: hass, MockModule("test", async_setup=mock_setup, async_setup_entry=mock_setup_entry), ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry.add_to_hass(hass) @@ -3938,7 +3938,7 @@ async def test_initializing_flows_canceled_on_shutdown( await asyncio.sleep(1) mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch.dict( config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} @@ -4010,7 +4010,7 @@ async def test_preview_supported( preview_calls.append(None) mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) assert len(preview_calls) == 0 @@ -4046,7 +4046,7 @@ async def test_preview_not_supported( raise NotImplementedError mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch.dict( config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} diff --git a/tests/test_setup.py b/tests/test_setup.py index eb4c645ecb1..8b3b79ac48c 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -23,8 +23,8 @@ from .common import ( MockModule, MockPlatform, assert_setup_component, - mock_entity_platform, mock_integration, + mock_platform, ) @@ -90,9 +90,9 @@ async def test_validate_platform_config( hass, MockModule("platform_conf", platform_schema_base=platform_schema_base), ) - mock_entity_platform( + mock_platform( hass, - "platform_conf.whatever", + "whatever.platform_conf", MockPlatform(platform_schema=platform_schema), ) @@ -156,9 +156,9 @@ async def test_validate_platform_config_2( ), ) - mock_entity_platform( + mock_platform( hass, - "platform_conf.whatever", + "whatever.platform_conf", MockPlatform("whatever", platform_schema=platform_schema), ) @@ -185,9 +185,9 @@ async def test_validate_platform_config_3( hass, MockModule("platform_conf", platform_schema=component_schema) ) - mock_entity_platform( + mock_platform( hass, - "platform_conf.whatever", + "whatever.platform_conf", MockPlatform("whatever", platform_schema=platform_schema), ) @@ -213,9 +213,9 @@ async def test_validate_platform_config_4(hass: HomeAssistant) -> None: MockModule("platform_conf", platform_schema_base=component_schema), ) - mock_entity_platform( + mock_platform( hass, - "platform_conf.whatever", + "whatever.platform_conf", MockPlatform(platform_schema=platform_schema), ) @@ -350,7 +350,7 @@ async def test_component_setup_with_validation_and_dependency( MockModule("platform_a", setup=config_check_setup, dependencies=["comp_a"]), ) - mock_entity_platform(hass, "switch.platform_a", platform) + mock_platform(hass, "platform_a.switch", platform) await setup.async_setup_component( hass, @@ -367,9 +367,9 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: mock_setup = Mock(spec_set=True) - mock_entity_platform( + mock_platform( hass, - "switch.platform_a", + "platform_a.switch", MockPlatform(platform_schema=platform_schema, setup_platform=mock_setup), ) @@ -618,7 +618,7 @@ async def test_parallel_entry_setup(hass: HomeAssistant, mock_handlers) -> None: async_setup_entry=mock_async_setup_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) await setup.async_setup_component(hass, "comp", {}) assert calls == [1, 2, 1, 2] From b3e247d5f03c7d934ee96d361643545640402c84 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 16 Nov 2023 10:28:06 -0600 Subject: [PATCH 536/982] Add websocket command to capture audio from a device (#103936) * Add websocket command to capture audio from a device * Update homeassistant/components/assist_pipeline/pipeline.py Co-authored-by: Paulus Schoutsen * Add device capture test * More tests * Add logbook * Remove unnecessary check * Remove seconds and make logbook message past tense --------- Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/__init__.py | 9 +- .../components/assist_pipeline/const.py | 2 + .../components/assist_pipeline/logbook.py | 39 ++ .../components/assist_pipeline/pipeline.py | 62 +++- .../assist_pipeline/websocket_api.py | 119 +++++- .../snapshots/test_websocket.ambr | 113 ++++++ .../assist_pipeline/test_logbook.py | 42 +++ .../assist_pipeline/test_websocket.py | 350 +++++++++++++++++- 8 files changed, 720 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/assist_pipeline/logbook.py create mode 100644 tests/components/assist_pipeline/test_logbook.py diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 64fe9e1f5f4..6d00f26ee15 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -9,7 +9,13 @@ from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, DOMAIN +from .const import ( + CONF_DEBUG_RECORDING_DIR, + DATA_CONFIG, + DATA_LAST_WAKE_UP, + DOMAIN, + EVENT_RECORDING, +) from .error import PipelineNotFound from .pipeline import ( AudioSettings, @@ -40,6 +46,7 @@ __all__ = ( "PipelineEventType", "PipelineNotFound", "WakeWordSettings", + "EVENT_RECORDING", ) CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index 84b49fc18fa..091b19db69e 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -11,3 +11,5 @@ CONF_DEBUG_RECORDING_DIR = "debug_recording_dir" DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up" DEFAULT_WAKE_WORD_COOLDOWN = 2 # seconds + +EVENT_RECORDING = f"{DOMAIN}_recording" diff --git a/homeassistant/components/assist_pipeline/logbook.py b/homeassistant/components/assist_pipeline/logbook.py new file mode 100644 index 00000000000..f2cfb8d3d5e --- /dev/null +++ b/homeassistant/components/assist_pipeline/logbook.py @@ -0,0 +1,39 @@ +"""Describe assist_pipeline logbook events.""" +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr + +from .const import DOMAIN, EVENT_RECORDING + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Describe logbook events.""" + device_registry = dr.async_get(hass) + + @callback + def async_describe_logbook_event(event: Event) -> dict[str, str]: + """Describe logbook event.""" + device: dr.DeviceEntry | None = None + device_name: str = "Unknown device" + + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + if device: + device_name = device.name_by_user or device.name or "Unknown device" + + message = f"{device_name} started recording audio" + + return { + LOGBOOK_ENTRY_NAME: device_name, + LOGBOOK_ENTRY_MESSAGE: message, + } + + async_describe_event(DOMAIN, EVENT_RECORDING, async_describe_logbook_event) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index c6d0f6c5435..71e93371257 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -503,6 +503,9 @@ class PipelineRun: audio_processor_buffer: AudioBuffer = field(init=False, repr=False) """Buffer used when splitting audio into chunks for audio processing""" + _device_id: str | None = None + """Optional device id set during run start.""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -554,7 +557,8 @@ class PipelineRun: def start(self, device_id: str | None) -> None: """Emit run start event.""" - self._start_debug_recording_thread(device_id) + self._device_id = device_id + self._start_debug_recording_thread() data = { "pipeline": self.pipeline.id, @@ -567,6 +571,9 @@ class PipelineRun: async def end(self) -> None: """Emit run end event.""" + # Signal end of stream to listeners + self._capture_chunk(None) + # Stop the recording thread before emitting run-end. # This ensures that files are properly closed if the event handler reads them. await self._stop_debug_recording_thread() @@ -746,9 +753,7 @@ class PipelineRun: if self.abort_wake_word_detection: raise WakeWordDetectionAborted - if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk.audio) - + self._capture_chunk(chunk.audio) yield chunk.audio, chunk.timestamp_ms # Wake-word-detection occurs *after* the wake word was actually @@ -870,8 +875,7 @@ class PipelineRun: chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate sent_vad_start = False async for chunk in audio_stream: - if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk.audio) + self._capture_chunk(chunk.audio) if stt_vad is not None: if not stt_vad.process(chunk_seconds, chunk.is_speech): @@ -1057,7 +1061,28 @@ class PipelineRun: return tts_media.url - def _start_debug_recording_thread(self, device_id: str | None) -> None: + def _capture_chunk(self, audio_bytes: bytes | None) -> None: + """Forward audio chunk to various capturing mechanisms.""" + if self.debug_recording_queue is not None: + # Forward to debug WAV file recording + self.debug_recording_queue.put_nowait(audio_bytes) + + if self._device_id is None: + return + + # Forward to device audio capture + pipeline_data: PipelineData = self.hass.data[DOMAIN] + audio_queue = pipeline_data.device_audio_queues.get(self._device_id) + if audio_queue is None: + return + + try: + audio_queue.queue.put_nowait(audio_bytes) + except asyncio.QueueFull: + audio_queue.overflow = True + _LOGGER.warning("Audio queue full for device %s", self._device_id) + + def _start_debug_recording_thread(self) -> None: """Start thread to record wake/stt audio if debug_recording_dir is set.""" if self.debug_recording_thread is not None: # Already started @@ -1068,7 +1093,7 @@ class PipelineRun: if debug_recording_dir := self.hass.data[DATA_CONFIG].get( CONF_DEBUG_RECORDING_DIR ): - if device_id is None: + if self._device_id is None: # // run_recording_dir = ( Path(debug_recording_dir) @@ -1079,7 +1104,7 @@ class PipelineRun: # /// run_recording_dir = ( Path(debug_recording_dir) - / device_id + / self._device_id / self.pipeline.name / str(time.monotonic_ns()) ) @@ -1100,8 +1125,8 @@ class PipelineRun: # Not running return - # Signal thread to stop gracefully - self.debug_recording_queue.put(None) + # NOTE: Expecting a None to have been put in self.debug_recording_queue + # in self.end() to signal the thread to stop. # Wait until the thread has finished to ensure that files are fully written await self.hass.async_add_executor_job(self.debug_recording_thread.join) @@ -1632,6 +1657,20 @@ class PipelineRuns: pipeline_run.abort_wake_word_detection = True +@dataclass +class DeviceAudioQueue: + """Audio capture queue for a satellite device.""" + + queue: asyncio.Queue[bytes | None] + """Queue of audio chunks (None = stop signal)""" + + id: str = field(default_factory=ulid_util.ulid) + """Unique id to ensure the correct audio queue is cleaned up in websocket API.""" + + overflow: bool = False + """Flag to be set if audio samples were dropped because the queue was full.""" + + class PipelineData: """Store and debug data stored in hass.data.""" @@ -1641,6 +1680,7 @@ class PipelineData: self.pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] = {} self.pipeline_devices: set[str] = set() self.pipeline_runs = PipelineRuns(pipeline_store) + self.device_audio_queues: dict[str, DeviceAudioQueue] = {} @dataclass diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index fda3e266490..6bfe969dc3e 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -3,22 +3,31 @@ import asyncio # Suppressing disable=deprecated-module is needed for Python 3.11 import audioop # pylint: disable=deprecated-module +import base64 from collections.abc import AsyncGenerator, Callable +import contextlib import logging -from typing import Any +import math +from typing import Any, Final import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api -from homeassistant.const import MATCH_ALL +from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.util import language as language_util -from .const import DEFAULT_PIPELINE_TIMEOUT, DEFAULT_WAKE_WORD_TIMEOUT, DOMAIN +from .const import ( + DEFAULT_PIPELINE_TIMEOUT, + DEFAULT_WAKE_WORD_TIMEOUT, + DOMAIN, + EVENT_RECORDING, +) from .error import PipelineNotFound from .pipeline import ( AudioSettings, + DeviceAudioQueue, PipelineData, PipelineError, PipelineEvent, @@ -32,6 +41,11 @@ from .pipeline import ( _LOGGER = logging.getLogger(__name__) +CAPTURE_RATE: Final = 16000 +CAPTURE_WIDTH: Final = 2 +CAPTURE_CHANNELS: Final = 1 +MAX_CAPTURE_TIMEOUT: Final = 60.0 + @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -40,6 +54,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_list_languages) websocket_api.async_register_command(hass, websocket_list_runs) websocket_api.async_register_command(hass, websocket_get_run) + websocket_api.async_register_command(hass, websocket_device_capture) @websocket_api.websocket_command( @@ -371,3 +386,101 @@ async def websocket_list_languages( else pipeline_languages }, ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/device/capture", + vol.Required("device_id"): str, + vol.Required("timeout"): vol.All( + # 0 < timeout <= MAX_CAPTURE_TIMEOUT + vol.Coerce(float), + vol.Range(min=0, min_included=False, max=MAX_CAPTURE_TIMEOUT), + ), + } +) +@websocket_api.async_response +async def websocket_device_capture( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Capture raw audio from a satellite device and forward to client.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + device_id = msg["device_id"] + + # Number of seconds to record audio in wall clock time + timeout_seconds = msg["timeout"] + + # We don't know the chunk size, so the upper bound is calculated assuming a + # single sample (16 bits) per queue item. + max_queue_items = ( + # +1 for None to signal end + int(math.ceil(timeout_seconds * CAPTURE_RATE)) + + 1 + ) + + audio_queue = DeviceAudioQueue(queue=asyncio.Queue(maxsize=max_queue_items)) + + # Running simultaneous captures for a single device will not work by design. + # The new capture will cause the old capture to stop. + if ( + old_audio_queue := pipeline_data.device_audio_queues.pop(device_id, None) + ) is not None: + with contextlib.suppress(asyncio.QueueFull): + # Signal other websocket command that we're taking over + old_audio_queue.queue.put_nowait(None) + + # Only one client can be capturing audio at a time + pipeline_data.device_audio_queues[device_id] = audio_queue + + def clean_up_queue() -> None: + # Clean up our audio queue + maybe_audio_queue = pipeline_data.device_audio_queues.get(device_id) + if (maybe_audio_queue is not None) and (maybe_audio_queue.id == audio_queue.id): + # Only pop if this is our queue + pipeline_data.device_audio_queues.pop(device_id) + + # Unsubscribe cleans up queue + connection.subscriptions[msg["id"]] = clean_up_queue + + # Audio will follow as events + connection.send_result(msg["id"]) + + # Record to logbook + hass.bus.async_fire( + EVENT_RECORDING, + { + ATTR_DEVICE_ID: device_id, + ATTR_SECONDS: timeout_seconds, + }, + ) + + try: + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(timeout_seconds): + while True: + # Send audio chunks encoded as base64 + audio_bytes = await audio_queue.queue.get() + if audio_bytes is None: + # Signal to stop + break + + connection.send_event( + msg["id"], + { + "type": "audio", + "rate": CAPTURE_RATE, # hertz + "width": CAPTURE_WIDTH, # bytes + "channels": CAPTURE_CHANNELS, + "audio": base64.b64encode(audio_bytes).decode("ascii"), + }, + ) + + # Capture has ended + connection.send_event( + msg["id"], {"type": "end", "overflow": audio_queue.overflow} + ) + finally: + clean_up_queue() diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 9eb7e1e5a05..1f625528806 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -487,6 +487,119 @@ # name: test_audio_pipeline_with_wake_word_timeout.3 None # --- +# name: test_device_capture + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_device_capture.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_device_capture.2 + None +# --- +# name: test_device_capture_override + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_device_capture_override.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_device_capture_override.2 + dict({ + 'audio': 'Y2h1bmsx', + 'channels': 1, + 'rate': 16000, + 'type': 'audio', + 'width': 2, + }) +# --- +# name: test_device_capture_override.3 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_device_capture_override.4 + None +# --- +# name: test_device_capture_override.5 + dict({ + 'overflow': False, + 'type': 'end', + }) +# --- +# name: test_device_capture_queue_full + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_device_capture_queue_full.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_device_capture_queue_full.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_device_capture_queue_full.3 + None +# --- +# name: test_device_capture_queue_full.4 + dict({ + 'overflow': True, + 'type': 'end', + }) +# --- # name: test_intent_failed dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_logbook.py b/tests/components/assist_pipeline/test_logbook.py new file mode 100644 index 00000000000..6a997236f1c --- /dev/null +++ b/tests/components/assist_pipeline/test_logbook.py @@ -0,0 +1,42 @@ +"""The tests for assist_pipeline logbook.""" +from homeassistant.components import assist_pipeline, logbook +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.logbook.common import MockRow, mock_humanify + + +async def test_recording_event( + hass: HomeAssistant, init_components, device_registry: dr.DeviceRegistry +) -> None: + """Test recording event.""" + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "satellite-1234")}, + ) + + device_registry.async_update_device(satellite_device.id, name="My Satellite") + event = mock_humanify( + hass, + [ + MockRow( + assist_pipeline.EVENT_RECORDING, + {ATTR_DEVICE_ID: satellite_device.id}, + ), + ], + )[0] + + assert event[logbook.LOGBOOK_ENTRY_NAME] == "My Satellite" + assert event[logbook.LOGBOOK_ENTRY_DOMAIN] == assist_pipeline.DOMAIN + assert ( + event[logbook.LOGBOOK_ENTRY_MESSAGE] == "My Satellite started recording audio" + ) diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 9a4e78a29af..931b31dd77b 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -1,16 +1,23 @@ """Websocket tests for Voice Assistant integration.""" import asyncio +import base64 from unittest.mock import ANY, patch from syrupy.assertion import SnapshotAssertion from homeassistant.components.assist_pipeline.const import DOMAIN -from homeassistant.components.assist_pipeline.pipeline import Pipeline, PipelineData +from homeassistant.components.assist_pipeline.pipeline import ( + DeviceAudioQueue, + Pipeline, + PipelineData, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from .conftest import MockWakeWordEntity, MockWakeWordEntity2 +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -2104,3 +2111,344 @@ async def test_wake_word_cooldown_different_entities( # Wake words should be the same assert ww_id_1 == ww_id_2 + + +async def test_device_capture( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test audio capture from a satellite device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "satellite-1234")}, + ) + + audio_chunks = [b"chunk1", b"chunk2", b"chunk3"] + + # Start capture + client_capture = await hass_ws_client(hass) + await client_capture.send_json_auto_id( + { + "type": "assist_pipeline/device/capture", + "timeout": 30, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_capture.receive_json() + assert msg["success"] + + # Run pipeline + client_pipeline = await hass_ws_client(hass) + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "stt", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_pipeline.receive_json() + assert msg["success"] + + # run start + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + + # stt + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + + for audio_chunk in audio_chunks: + await client_pipeline.send_bytes(bytes([handler_id]) + audio_chunk) + + # End of audio stream + await client_pipeline.send_bytes(bytes([handler_id])) + + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-end" + + # run end + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + + # Verify capture + events = [] + async with asyncio.timeout(1): + while True: + msg = await client_capture.receive_json() + assert msg["type"] == "event" + event_data = msg["event"] + events.append(event_data) + if event_data["type"] == "end": + break + + assert len(events) == len(audio_chunks) + 1 + + # Verify audio chunks + for i, audio_chunk in enumerate(audio_chunks): + assert events[i]["type"] == "audio" + assert events[i]["rate"] == 16000 + assert events[i]["width"] == 2 + assert events[i]["channels"] == 1 + + # Audio is base64 encoded + assert events[i]["audio"] == base64.b64encode(audio_chunk).decode("ascii") + + # Last event is the end + assert events[-1]["type"] == "end" + + +async def test_device_capture_override( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test overriding an existing audio capture from a satellite device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "satellite-1234")}, + ) + + audio_chunks = [b"chunk1", b"chunk2", b"chunk3"] + + # Start first capture + client_capture_1 = await hass_ws_client(hass) + await client_capture_1.send_json_auto_id( + { + "type": "assist_pipeline/device/capture", + "timeout": 30, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_capture_1.receive_json() + assert msg["success"] + + # Run pipeline + client_pipeline = await hass_ws_client(hass) + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "stt", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_pipeline.receive_json() + assert msg["success"] + + # run start + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + + # stt + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + + # Send first audio chunk + await client_pipeline.send_bytes(bytes([handler_id]) + audio_chunks[0]) + + # Verify first capture + msg = await client_capture_1.receive_json() + assert msg["type"] == "event" + assert msg["event"] == snapshot + assert msg["event"]["audio"] == base64.b64encode(audio_chunks[0]).decode("ascii") + + # Start a new capture + client_capture_2 = await hass_ws_client(hass) + await client_capture_2.send_json_auto_id( + { + "type": "assist_pipeline/device/capture", + "timeout": 30, + "device_id": satellite_device.id, + } + ) + + # result (capture 2) + msg = await client_capture_2.receive_json() + assert msg["success"] + + # Send remaining audio chunks + for audio_chunk in audio_chunks[1:]: + await client_pipeline.send_bytes(bytes([handler_id]) + audio_chunk) + + # End of audio stream + await client_pipeline.send_bytes(bytes([handler_id])) + + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + + # run end + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + + # Verify that first capture ended with no more audio + msg = await client_capture_1.receive_json() + assert msg["type"] == "event" + assert msg["event"] == snapshot + assert msg["event"]["type"] == "end" + + # Verify that the second capture got the remaining audio + events = [] + async with asyncio.timeout(1): + while True: + msg = await client_capture_2.receive_json() + assert msg["type"] == "event" + event_data = msg["event"] + events.append(event_data) + if event_data["type"] == "end": + break + + # -1 since first audio chunk went to the first capture + assert len(events) == len(audio_chunks) + + # Verify all but first audio chunk + for i, audio_chunk in enumerate(audio_chunks[1:]): + assert events[i]["type"] == "audio" + assert events[i]["rate"] == 16000 + assert events[i]["width"] == 2 + assert events[i]["channels"] == 1 + + # Audio is base64 encoded + assert events[i]["audio"] == base64.b64encode(audio_chunk).decode("ascii") + + # Last event is the end + assert events[-1]["type"] == "end" + + +async def test_device_capture_queue_full( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test audio capture from a satellite device when the recording queue fills up.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "satellite-1234")}, + ) + + class FakeQueue(asyncio.Queue): + """Queue that reports full for anything but None.""" + + def put_nowait(self, item): + if item is not None: + raise asyncio.QueueFull() + + super().put_nowait(item) + + with patch( + "homeassistant.components.assist_pipeline.websocket_api.DeviceAudioQueue" + ) as mock: + mock.return_value = DeviceAudioQueue(queue=FakeQueue()) + + # Start capture + client_capture = await hass_ws_client(hass) + await client_capture.send_json_auto_id( + { + "type": "assist_pipeline/device/capture", + "timeout": 30, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_capture.receive_json() + assert msg["success"] + + # Run pipeline + client_pipeline = await hass_ws_client(hass) + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "stt", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_pipeline.receive_json() + assert msg["success"] + + # run start + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + + # stt + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + + # Single sample will "overflow" the queue + await client_pipeline.send_bytes(bytes([handler_id, 0, 0])) + + # End of audio stream + await client_pipeline.send_bytes(bytes([handler_id])) + + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + + # Queue should have been overflowed + async with asyncio.timeout(1): + msg = await client_capture.receive_json() + assert msg["type"] == "event" + assert msg["event"] == snapshot + assert msg["event"]["type"] == "end" + assert msg["event"]["overflow"] From a996a51aa943c68b6d4d28f07b85013bff478506 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 16 Nov 2023 21:24:32 +0100 Subject: [PATCH 537/982] Add "Jasco Products" manufacturer to ZHA `ForceOnLight` (#104089) * Add "Jasco Products" manufacturer to ZHA `ForceOnLight` * Change tests to expect `ForceOnLight` for "Jasco Products" lights --- homeassistant/components/zha/light.py | 2 +- tests/components/zha/zha_devices_list.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 6a01d550466..d545a331a6d 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1072,7 +1072,7 @@ class HueLight(Light): @STRICT_MATCH( cluster_handler_names=CLUSTER_HANDLER_ON_OFF, aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, - manufacturers={"Jasco", "Quotra-Vision", "eWeLight", "eWeLink"}, + manufacturers={"Jasco", "Jasco Products", "Quotra-Vision", "eWeLight", "eWeLink"}, ) class ForceOnLight(Light): """Representation of a light which does not respect on/off for move_to_level_with_on_off commands.""" diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 44f01555b19..c193cd509f3 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1492,7 +1492,7 @@ DEVICES = [ DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], - DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45852_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -1547,7 +1547,7 @@ DEVICES = [ DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CLUSTER_HANDLERS: ["on_off"], - DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45856_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -1602,7 +1602,7 @@ DEVICES = [ DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], - DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45857_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { From cd9ad32e329a2106b303ed30c661d668347ce02d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 16 Nov 2023 21:56:36 +0100 Subject: [PATCH 538/982] Add catsmanac to enphase_envoy codeowners (#104086) --- CODEOWNERS | 4 ++-- homeassistant/components/enphase_envoy/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 1be408045cc..48d2003d861 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -345,8 +345,8 @@ build.json @home-assistant/supervisor /homeassistant/components/enigma2/ @fbradyirl /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer -/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek -/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek +/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac +/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac /homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 718c33d2811..b52a09f1f57 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -1,7 +1,7 @@ { "domain": "enphase_envoy", "name": "Enphase Envoy", - "codeowners": ["@bdraco", "@cgarwood", "@dgomes", "@joostlek"], + "codeowners": ["@bdraco", "@cgarwood", "@dgomes", "@joostlek", "@catsmanac"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", From 0a3b20d8b2d8c5e5144829e19b00658196c86ab3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 16 Nov 2023 23:10:33 +0100 Subject: [PATCH 539/982] Replace deprecated linting and formatting settings by extensions (#104050) --- .devcontainer/devcontainer.json | 10 ++-------- .vscode/settings.default.json | 1 - 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 27e2d2e5ad0..83ee0a2e422 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,6 +10,8 @@ "customizations": { "vscode": { "extensions": [ + "ms-python.black-formatter", + "ms-python.pylint", "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", "redhat.vscode-yaml", @@ -19,14 +21,6 @@ // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json "settings": { "python.pythonPath": "/usr/local/bin/python", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.formatting.blackPath": "/usr/local/bin/black", - "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", - "python.linting.mypyPath": "/usr/local/bin/mypy", - "python.linting.pylintPath": "/usr/local/bin/pylint", - "python.formatting.provider": "black", "python.testing.pytestArgs": ["--no-cov"], "editor.formatOnPaste": false, "editor.formatOnSave": true, diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json index 3765d1251b8..e0792a360f1 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.json @@ -1,6 +1,5 @@ { // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json - "python.formatting.provider": "black", // Added --no-cov to work around TypeError: message must be set // https://github.com/microsoft/vscode-python/issues/14067 "python.testing.pytestArgs": ["--no-cov"], From ea6a26467e0753073f2ca8ed9c80f8467620a483 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 16 Nov 2023 23:12:10 +0100 Subject: [PATCH 540/982] Add myself as codeowner for Proximity (#104100) add myself as codeowner for proximity --- CODEOWNERS | 2 ++ homeassistant/components/proximity/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 48d2003d861..a28c164219a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -977,6 +977,8 @@ build.json @home-assistant/supervisor /tests/components/prometheus/ @knyar /homeassistant/components/prosegur/ @dgomes /tests/components/prosegur/ @dgomes +/homeassistant/components/proximity/ @mib1185 +/tests/components/proximity/ @mib1185 /homeassistant/components/proxmoxve/ @jhollowe @Corbeno /homeassistant/components/prusalink/ @balloob /tests/components/prusalink/ @balloob diff --git a/homeassistant/components/proximity/manifest.json b/homeassistant/components/proximity/manifest.json index c09a03b2438..3f1ea950d0e 100644 --- a/homeassistant/components/proximity/manifest.json +++ b/homeassistant/components/proximity/manifest.json @@ -1,7 +1,7 @@ { "domain": "proximity", "name": "Proximity", - "codeowners": [], + "codeowners": ["@mib1185"], "dependencies": ["device_tracker", "zone"], "documentation": "https://www.home-assistant.io/integrations/proximity", "iot_class": "calculated", From ef5c9c2187bec447c4c0d7e527d13f256febb26a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Nov 2023 16:13:13 -0600 Subject: [PATCH 541/982] Bump aioesphomeapi to 18.5.1 (#104085) * Bump aioesphomeapi to 18.5.0 changelog: https://github.com/esphome/aioesphomeapi/compare/v18.4.0...v18.5.0 * one more --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 3b5a2050cb8..4ee6005ca8f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.4.0", + "aioesphomeapi==18.5.1", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 440e0ed180b..4db50e9192f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.4.0 +aioesphomeapi==18.5.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebcd5123c92..ba39460d7c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.4.0 +aioesphomeapi==18.5.1 # homeassistant.components.flo aioflo==2021.11.0 From 33d144fe2ddada6017c5a02a8326531fc16b8241 Mon Sep 17 00:00:00 2001 From: laurentriffard Date: Thu, 16 Nov 2023 23:29:44 +0100 Subject: [PATCH 542/982] Set nextcloud integration sensors as numerical values (#103856) --- homeassistant/components/nextcloud/sensor.py | 39 ++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 16c8adb77ce..8344fb033b7 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -42,24 +43,28 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="activeUsers_last1hour", translation_key="nextcloud_activeusers_last1hour", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), NextcloudSensorEntityDescription( key="activeUsers_last24hours", translation_key="nextcloud_activeusers_last24hours", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), NextcloudSensorEntityDescription( key="activeUsers_last5minutes", translation_key="nextcloud_activeusers_last5minutes", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), NextcloudSensorEntityDescription( key="cache_expunges", translation_key="nextcloud_cache_expunges", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -81,30 +86,35 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="cache_num_entries", translation_key="nextcloud_cache_num_entries", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="cache_num_hits", translation_key="nextcloud_cache_num_hits", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="cache_num_inserts", translation_key="nextcloud_cache_num_inserts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="cache_num_misses", translation_key="nextcloud_cache_num_misses", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="cache_num_slots", translation_key="nextcloud_cache_num_slots", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -166,6 +176,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="interned_strings_usage_number_of_strings", translation_key="nextcloud_interned_strings_usage_number_of_strings", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -220,6 +231,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="opcache_statistics_blacklist_miss_ratio", translation_key="nextcloud_opcache_statistics_blacklist_miss_ratio", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, @@ -227,18 +239,21 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="opcache_statistics_blacklist_misses", translation_key="nextcloud_opcache_statistics_blacklist_misses", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_hash_restarts", translation_key="nextcloud_opcache_statistics_hash_restarts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_hits", translation_key="nextcloud_opcache_statistics_hits", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -253,36 +268,42 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="opcache_statistics_manual_restarts", translation_key="nextcloud_opcache_statistics_manual_restarts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_max_cached_keys", translation_key="nextcloud_opcache_statistics_max_cached_keys", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_misses", translation_key="nextcloud_opcache_statistics_misses", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_num_cached_keys", translation_key="nextcloud_opcache_statistics_num_cached_keys", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_num_cached_scripts", translation_key="nextcloud_opcache_statistics_num_cached_scripts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_oom_restarts", translation_key="nextcloud_opcache_statistics_oom_restarts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -386,45 +407,54 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="shares_num_fed_shares_sent", translation_key="nextcloud_shares_num_fed_shares_sent", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_fed_shares_received", translation_key="nextcloud_shares_num_fed_shares_received", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares", translation_key="nextcloud_shares_num_shares", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="shares_num_shares_groups", translation_key="nextcloud_shares_num_shares_groups", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_link", translation_key="nextcloud_shares_num_shares_link", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_link_no_password", translation_key="nextcloud_shares_num_shares_link_no_password", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_mail", translation_key="nextcloud_shares_num_shares_mail", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_room", translation_key="nextcloud_shares_num_shares_room", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_user", translation_key="nextcloud_shares_num_shares_user", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( @@ -440,6 +470,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="sma_num_seg", translation_key="nextcloud_sma_num_seg", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -456,37 +487,45 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="storage_num_files", translation_key="nextcloud_storage_num_files", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="storage_num_storages", translation_key="nextcloud_storage_num_storages", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="storage_num_storages_home", translation_key="nextcloud_storage_num_storages_home", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="storage_num_storages_local", translation_key="nextcloud_storage_num_storages_local", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="storage_num_storages_other", translation_key="nextcloud_storage_num_storages_other", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="storage_num_users", translation_key="nextcloud_storage_num_users", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="system_apps_num_installed", translation_key="nextcloud_system_apps_num_installed", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="system_apps_num_updates_available", translation_key="nextcloud_system_apps_num_updates_available", + state_class=SensorStateClass.MEASUREMENT, icon="mdi:update", ), NextcloudSensorEntityDescription( From d5885e0da2f27f6ce30dbcf62d2429d8eb824216 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Thu, 16 Nov 2023 17:44:43 -0500 Subject: [PATCH 543/982] Bump pyinsteon to 1.5.2 (#104098) bump pyinsteon --- homeassistant/components/insteon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 5fa45a16fb6..1d4eee4a058 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.5.1", + "pyinsteon==1.5.2", "insteon-frontend-home-assistant==0.4.0" ], "usb": [ diff --git a/requirements_all.txt b/requirements_all.txt index 4db50e9192f..02dd6b5f7dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1788,7 +1788,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.1 +pyinsteon==1.5.2 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba39460d7c0..5928a24b12b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1347,7 +1347,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.1 +pyinsteon==1.5.2 # homeassistant.components.ipma pyipma==3.0.7 From f605df5bf285d1a385b03c2aa3ab0f8af8b5b2a0 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 16 Nov 2023 22:39:08 -0600 Subject: [PATCH 544/982] Adjust logbook message for assist pipeline recording (#104105) * Adjust logbook message * Fix test --- homeassistant/components/assist_pipeline/logbook.py | 2 +- tests/components/assist_pipeline/test_logbook.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_pipeline/logbook.py b/homeassistant/components/assist_pipeline/logbook.py index f2cfb8d3d5e..0c00c57adb9 100644 --- a/homeassistant/components/assist_pipeline/logbook.py +++ b/homeassistant/components/assist_pipeline/logbook.py @@ -29,7 +29,7 @@ def async_describe_events( if device: device_name = device.name_by_user or device.name or "Unknown device" - message = f"{device_name} started recording audio" + message = f"{device_name} captured an audio sample" return { LOGBOOK_ENTRY_NAME: device_name, diff --git a/tests/components/assist_pipeline/test_logbook.py b/tests/components/assist_pipeline/test_logbook.py index 6a997236f1c..c1e0633ed57 100644 --- a/tests/components/assist_pipeline/test_logbook.py +++ b/tests/components/assist_pipeline/test_logbook.py @@ -38,5 +38,5 @@ async def test_recording_event( assert event[logbook.LOGBOOK_ENTRY_NAME] == "My Satellite" assert event[logbook.LOGBOOK_ENTRY_DOMAIN] == assist_pipeline.DOMAIN assert ( - event[logbook.LOGBOOK_ENTRY_MESSAGE] == "My Satellite started recording audio" + event[logbook.LOGBOOK_ENTRY_MESSAGE] == "My Satellite captured an audio sample" ) From cf9299df59cbda8bdf3e4877a45eecce917576fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Nov 2023 23:22:13 -0600 Subject: [PATCH 545/982] Avoid duplicate calls to color_supported and color_temp_supported in emulated_hue (#104096) --- homeassistant/components/emulated_hue/hue_api.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 4dbe5aa315e..ad6b0541cd6 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -772,7 +772,9 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: "swversion": "123", } - if light.color_supported(color_modes) and light.color_temp_supported(color_modes): + color_supported = light.color_supported(color_modes) + color_temp_supported = light.color_temp_supported(color_modes) + if color_supported and color_temp_supported: # Extended Color light (Zigbee Device ID: 0x0210) # Same as Color light, but which supports additional setting of color temperature retval["type"] = "Extended color light" @@ -790,7 +792,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: json_state[HUE_API_STATE_COLORMODE] = "hs" else: json_state[HUE_API_STATE_COLORMODE] = "ct" - elif light.color_supported(color_modes): + elif color_supported: # Color light (Zigbee Device ID: 0x0200) # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) retval["type"] = "Color light" @@ -804,7 +806,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: HUE_API_STATE_EFFECT: "none", } ) - elif light.color_temp_supported(color_modes): + elif color_temp_supported: # Color temperature light (Zigbee Device ID: 0x0220) # Supports groups, scenes, on/off, dimming, and setting of a color temperature retval["type"] = "Color temperature light" From b3ceb82700e5a5358a098b624b3d4fae62f9a7a2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Nov 2023 11:47:42 +0100 Subject: [PATCH 546/982] Add device fixtures and tests for HomeWizard Energy 3-phase kWh meter (#104118) --- .../homewizard/fixtures/SDM630/data.json | 46 + .../homewizard/fixtures/SDM630/device.json | 7 + .../homewizard/fixtures/SDM630/system.json | 3 + .../snapshots/test_diagnostics.ambr | 67 ++ .../homewizard/snapshots/test_sensor.ambr | 808 ++++++++++++++++++ tests/components/homewizard/test_button.py | 2 +- .../components/homewizard/test_diagnostics.py | 1 + tests/components/homewizard/test_number.py | 2 +- tests/components/homewizard/test_sensor.py | 57 ++ tests/components/homewizard/test_switch.py | 8 + 10 files changed, 999 insertions(+), 2 deletions(-) create mode 100644 tests/components/homewizard/fixtures/SDM630/data.json create mode 100644 tests/components/homewizard/fixtures/SDM630/device.json create mode 100644 tests/components/homewizard/fixtures/SDM630/system.json diff --git a/tests/components/homewizard/fixtures/SDM630/data.json b/tests/components/homewizard/fixtures/SDM630/data.json new file mode 100644 index 00000000000..593cf808efb --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM630/data.json @@ -0,0 +1,46 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 92, + "smr_version": null, + "meter_model": null, + "unique_meter_id": null, + "active_tariff": null, + "total_power_import_kwh": 0.101, + "total_power_import_t1_kwh": 0.101, + "total_power_import_t2_kwh": null, + "total_power_import_t3_kwh": null, + "total_power_import_t4_kwh": null, + "total_power_export_kwh": 0.523, + "total_power_export_t1_kwh": 0.523, + "total_power_export_t2_kwh": null, + "total_power_export_t3_kwh": null, + "total_power_export_t4_kwh": null, + "active_power_w": -900.194, + "active_power_l1_w": -1058.296, + "active_power_l2_w": 158.102, + "active_power_l3_w": 0.0, + "active_voltage_l1_v": null, + "active_voltage_l2_v": null, + "active_voltage_l3_v": null, + "active_current_l1_a": null, + "active_current_l2_a": null, + "active_current_l3_a": null, + "active_frequency_hz": null, + "voltage_sag_l1_count": null, + "voltage_sag_l2_count": null, + "voltage_sag_l3_count": null, + "voltage_swell_l1_count": null, + "voltage_swell_l2_count": null, + "voltage_swell_l3_count": null, + "any_power_fail_count": null, + "long_power_fail_count": null, + "active_power_average_w": null, + "monthly_power_peak_w": null, + "monthly_power_peak_timestamp": null, + "total_gas_m3": null, + "gas_timestamp": null, + "gas_unique_id": null, + "active_liter_lpm": null, + "total_liter_m3": null, + "external_devices": null +} diff --git a/tests/components/homewizard/fixtures/SDM630/device.json b/tests/components/homewizard/fixtures/SDM630/device.json new file mode 100644 index 00000000000..b8ec1d18fe8 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM630/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "SDM630-wifi", + "product_name": "KWh meter 3-phase", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/SDM630/system.json b/tests/components/homewizard/fixtures/SDM630/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM630/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index a5c3e6ed8ba..8632f1ec008 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -275,3 +275,70 @@ }), }) # --- +# name: test_diagnostics[SDM630] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': None, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_l1_w': -1058.296, + 'active_power_l2_w': 158.102, + 'active_power_l3_w': 0.0, + 'active_power_w': -900.194, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': 0.523, + 'total_energy_export_t1_kwh': 0.523, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 0.101, + 'total_energy_import_t1_kwh': 0.101, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 92, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'SDM630-wifi', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': None, + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 4f1db0ac751..a9198ff4337 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -4203,3 +4203,811 @@ 'state': '92', }) # --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-900.194', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l2_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '158.102', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l3_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index a7b7d0917e6..25ef73e1459 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -17,7 +17,7 @@ pytestmark = [ ] -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230"]) +@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) async def test_identify_button_entity_not_loaded_when_not_available( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index ab7432e8dbf..5a140fa70c8 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -17,6 +17,7 @@ from tests.typing import ClientSessionGenerator "HWE-SKT", "HWE-WTR", "SDM230", + "SDM630", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 0062e32e54e..9af4cac665c 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -91,7 +91,7 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230"]) +@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: """Does not load button when device has no support for it.""" assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 04795a5e191..6471f89a4de 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -91,6 +91,21 @@ pytestmark = [ "sensor.device_active_power_phase_1", ], ), + ( + "SDM630", + [ + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + ], + ), ], ) async def test_sensors( @@ -151,6 +166,12 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "SDM630", + [ + "sensor.device_wi_fi_strength", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -266,6 +287,42 @@ async def test_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", ], ), + ( + "SDM630", + [ + "sensor.device_active_average_demand", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_active_tariff", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_water_usage", + "sensor.device_dsmr_version", + "sensor.device_gas_meter_identifier", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_gas", + "sensor.device_total_water_usage", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + ], + ), ], ) async def test_entities_not_created_for_device( diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 13a0bfaa863..0bb95a3a244 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -45,6 +45,14 @@ pytestmark = [ "switch.device_cloud_connection", ], ), + ( + "SDM630", + [ + "switch.device", + "switch.device_switch_lock", + "switch.device_cloud_connection", + ], + ), ], ) async def test_entities_not_created_for_device( From d70ef30a2a0e5787936e287006f0d5a3fdc55ff2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Nov 2023 12:58:33 +0100 Subject: [PATCH 547/982] Bump github/codeql-action from 2.22.6 to 2.22.7 (#104114) 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 9270eefe78b..7aa10a29762 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.6 + uses: github/codeql-action/init@v2.22.7 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.6 + uses: github/codeql-action/analyze@v2.22.7 with: category: "/language:python" From cd27b0e961565b97d0150aeb2f028a9b0bd5b6af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Nov 2023 05:59:14 -0600 Subject: [PATCH 548/982] Bump aioesphomeapi to 18.5.2 (#104113) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4ee6005ca8f..90de3af9f36 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.5.1", + "aioesphomeapi==18.5.2", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 02dd6b5f7dc..558b77468c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.1 +aioesphomeapi==18.5.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5928a24b12b..9c911580938 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.1 +aioesphomeapi==18.5.2 # homeassistant.components.flo aioflo==2021.11.0 From b58af167a2bd091eb769551d6d0bef5af9a6699c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 17 Nov 2023 13:01:06 +0100 Subject: [PATCH 549/982] Update RestrictedPython to 7.0 (#104117) --- homeassistant/components/python_script/manifest.json | 5 +---- pyproject.toml | 2 -- requirements_all.txt | 5 +---- requirements_test_all.txt | 5 +---- 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index bd034053a34..dcc0e38c737 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,8 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": [ - "RestrictedPython==6.2;python_version<'3.12'", - "RestrictedPython==7.0a1.dev0;python_version>='3.12'" - ] + "requirements": ["RestrictedPython==7.0"] } diff --git a/pyproject.toml b/pyproject.toml index 67657586ea5..b7adf946223 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -505,8 +505,6 @@ filterwarnings = [ "ignore:python-telegram-bot is using upstream urllib3:UserWarning:telegram.utils.request", # https://github.com/ludeeus/pytraccar/pull/15 - >1.0.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytraccar.client", - # https://github.com/zopefoundation/RestrictedPython/pull/259 - >7.0a1.dev0 - "ignore:ast\\.(Str|Num) is deprecated and will be removed in Python 3.14:DeprecationWarning:RestrictedPython.transformer", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # https://github.com/Bluetooth-Devices/xiaomi-ble/pull/59 - >0.21.1 diff --git a/requirements_all.txt b/requirements_all.txt index 558b77468c6..03a12b59131 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -121,10 +121,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.2;python_version<'3.12' - -# homeassistant.components.python_script -RestrictedPython==7.0a1.dev0;python_version>='3.12' +RestrictedPython==7.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c911580938..538ec4e2e9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,10 +106,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.2;python_version<'3.12' - -# homeassistant.components.python_script -RestrictedPython==7.0a1.dev0;python_version>='3.12' +RestrictedPython==7.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 From 97d38dae09ea69ea60f164a84740f978417e9d7f Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 17 Nov 2023 13:01:44 +0100 Subject: [PATCH 550/982] Bumb python-homewizard-energy to 4.1.0 (#104121) Co-authored-by: Franck Nijhof --- .../components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 8 +- .../homewizard/snapshots/test_switch.ambr | 170 +++++++++++++++++- tests/components/homewizard/test_switch.py | 13 +- 6 files changed, 176 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index ab3f4706970..949dda2a8aa 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==4.0.0"], + "requirements": ["python-homewizard-energy==4.1.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 03a12b59131..582d006da86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2143,7 +2143,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==4.0.0 +python-homewizard-energy==4.1.0 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 538ec4e2e9f..7fed131510e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ python-ecobee-api==0.2.17 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==4.0.0 +python-homewizard-energy==4.1.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 8632f1ec008..4fea0c3249e 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -265,7 +265,9 @@ 'serial': '**REDACTED**', }), 'state': None, - 'system': None, + 'system': dict({ + 'cloud_enabled': True, + }), }), 'entry': dict({ 'ip_address': '**REDACTED**', @@ -332,7 +334,9 @@ 'serial': '**REDACTED**', }), 'state': None, - 'system': None, + 'system': dict({ + 'cloud_enabled': True, + }), }), 'entry': dict({ 'ip_address': '**REDACTED**', diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index d38fab029d3..0fb4680a0b1 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT] +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -12,7 +12,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT].1 +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -43,7 +43,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT].2 +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -75,7 +75,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT] +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -88,7 +88,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT].1 +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT].2 +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -151,7 +151,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT] +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -164,7 +164,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT].1 +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -195,7 +195,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT].2 +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -227,3 +227,155 @@ 'via_device_id': None, }) # --- +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 0bb95a3a244..0b88f1ca949 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -42,7 +42,6 @@ pytestmark = [ [ "switch.device", "switch.device_switch_lock", - "switch.device_cloud_connection", ], ), ( @@ -50,7 +49,6 @@ pytestmark = [ [ "switch.device", "switch.device_switch_lock", - "switch.device_cloud_connection", ], ), ], @@ -64,13 +62,14 @@ async def test_entities_not_created_for_device( assert not hass.states.get(entity_id) -@pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) @pytest.mark.parametrize( - ("entity_id", "method", "parameter"), + ("device_fixture", "entity_id", "method", "parameter"), [ - ("switch.device", "state_set", "power_on"), - ("switch.device_switch_lock", "state_set", "switch_lock"), - ("switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("HWE-SKT", "switch.device", "state_set", "power_on"), + ("HWE-SKT", "switch.device_switch_lock", "state_set", "switch_lock"), + ("HWE-SKT", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("SDM230", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("SDM630", "switch.device_cloud_connection", "system_set", "cloud_enabled"), ], ) async def test_switch_entities( From 43c5bf27e69181a9b218c25f05867bda2436036a Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 17 Nov 2023 14:16:53 +0100 Subject: [PATCH 551/982] Bump pyenphase to 1.14.3 (#104101) fix(101354):update pyenphase to 1.14.3 --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index b52a09f1f57..c49e1f143e6 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.14.2"], + "requirements": ["pyenphase==1.14.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 582d006da86..d2b697df320 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1710,7 +1710,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.2 +pyenphase==1.14.3 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fed131510e..ae02f403f44 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1287,7 +1287,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.2 +pyenphase==1.14.3 # homeassistant.components.everlights pyeverlights==0.1.0 From a8acde62ff501c4d368c50c052b00fcbdaec48db Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 17 Nov 2023 07:34:14 -0600 Subject: [PATCH 552/982] Use device area as context during intent recognition (#103939) * Use device area as context during intent recognition * Use guard clauses --- .../components/conversation/default_agent.py | 45 ++++++- .../conversation/test_default_agent.py | 122 ++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 9dcf70dda80..c1282bbbac1 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -188,11 +188,14 @@ class DefaultAgent(AbstractConversationAgent): return None slot_lists = self._make_slot_lists() + intent_context = self._make_intent_context(user_input) + result = await self.hass.async_add_executor_job( self._recognize, user_input, lang_intents, slot_lists, + intent_context, ) return result @@ -221,15 +224,24 @@ class DefaultAgent(AbstractConversationAgent): # loaded in async_recognize. assert lang_intents is not None + # Include slot values from intent_context, such as the name of the + # device's area. + slots = { + entity_name: {"value": entity_value} + for entity_name, entity_value in result.context.items() + } + + # Override context with result entities + slots.update( + {entity.name: {"value": entity.value} for entity in result.entities_list} + ) + try: intent_response = await intent.async_handle( self.hass, DOMAIN, result.intent.name, - { - entity.name: {"value": entity.value} - for entity in result.entities_list - }, + slots, user_input.text, user_input.context, language, @@ -277,12 +289,16 @@ class DefaultAgent(AbstractConversationAgent): user_input: ConversationInput, lang_intents: LanguageIntents, slot_lists: dict[str, SlotList], + intent_context: dict[str, Any] | None, ) -> RecognizeResult | None: """Search intents for a match to user input.""" # Prioritize matches with entity names above area names maybe_result: RecognizeResult | None = None for result in recognize_all( - user_input.text, lang_intents.intents, slot_lists=slot_lists + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, ): if "name" in result.entities: return result @@ -623,6 +639,25 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists + def _make_intent_context( + self, user_input: ConversationInput + ) -> dict[str, Any] | None: + """Return intent recognition context for user input.""" + if not user_input.device_id: + return None + + devices = dr.async_get(self.hass) + device = devices.async_get(user_input.device_id) + if (device is None) or (device.area_id is None): + return None + + areas = ar.async_get(self.hass) + device_area = areas.async_get_area(device.area_id) + if device_area is None: + return None + + return {"area": device_area.name} + def _get_error_text( self, response_type: ResponseType, lang_intents: LanguageIntents | None ) -> str: diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index c75c96ca59b..bc85cdf604c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -1,4 +1,5 @@ """Test for the default agent.""" +from collections import defaultdict from unittest.mock import AsyncMock, patch import pytest @@ -293,3 +294,124 @@ async def test_nevermind_item(hass: HomeAssistant, init_components) -> None: assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert not result.response.speech + + +async def test_device_area_context( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that including a device_id will target a specific area.""" + turn_on_calls = async_mock_service(hass, "light", "turn_on") + turn_off_calls = async_mock_service(hass, "light", "turn_off") + + area_kitchen = area_registry.async_get_or_create("kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom") + + # Create 2 lights in each area + area_lights = defaultdict(list) + for area in (area_kitchen, area_bedroom): + for i in range(2): + light_entity = entity_registry.async_get_or_create( + "light", "demo", f"{area.name}-light-{i}" + ) + entity_registry.async_update_entity(light_entity.entity_id, area_id=area.id) + hass.states.async_set( + light_entity.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: f"{area.name} light {i}"}, + ) + area_lights[area.name].append(light_entity) + + # Create voice satellites in each area + entry = MockConfigEntry() + entry.add_to_hass(hass) + + kitchen_satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-satellite-kitchen")}, + ) + device_registry.async_update_device(kitchen_satellite.id, area_id=area_kitchen.id) + + bedroom_satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-satellite-bedroom")}, + ) + device_registry.async_update_device(bedroom_satellite.id, area_id=area_bedroom.id) + + # Turn on all lights in the area of a device + result = await conversation.async_converse( + hass, + "turn on all lights", + None, + Context(), + None, + device_id=kitchen_satellite.id, + ) + await hass.async_block_till_done() + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Verify only kitchen lights were targeted + assert {s.entity_id for s in result.response.matched_states} == { + e.entity_id for e in area_lights["kitchen"] + } + assert {c.data["entity_id"][0] for c in turn_on_calls} == { + e.entity_id for e in area_lights["kitchen"] + } + turn_on_calls.clear() + + # Ensure we can still target other areas by name + result = await conversation.async_converse( + hass, + "turn on all lights in the bedroom", + None, + Context(), + None, + device_id=kitchen_satellite.id, + ) + await hass.async_block_till_done() + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Verify only bedroom lights were targeted + assert {s.entity_id for s in result.response.matched_states} == { + e.entity_id for e in area_lights["bedroom"] + } + assert {c.data["entity_id"][0] for c in turn_on_calls} == { + e.entity_id for e in area_lights["bedroom"] + } + turn_on_calls.clear() + + # Turn off all lights in the area of the otherkj device + result = await conversation.async_converse( + hass, + "turn all lights off", + None, + Context(), + None, + device_id=bedroom_satellite.id, + ) + await hass.async_block_till_done() + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Verify only bedroom lights were targeted + assert {s.entity_id for s in result.response.matched_states} == { + e.entity_id for e in area_lights["bedroom"] + } + assert {c.data["entity_id"][0] for c in turn_off_calls} == { + e.entity_id for e in area_lights["bedroom"] + } + turn_off_calls.clear() + + # Not providing a device id should not match + for command in ("on", "off"): + result = await conversation.async_converse( + hass, f"turn {command} all lights", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + ) From 2ff9beb9c9e0f5ade2484c2546fd2f1cb8487eb9 Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Sat, 18 Nov 2023 02:36:28 +1300 Subject: [PATCH 553/982] Fix typo in Netatmo homekit auto-discovery (#104060) * Fix typo in Netatmo auto-discovery manifest.json * Update zeroconf.py to fix CI issue with Netatmo --- homeassistant/components/netatmo/manifest.json | 2 +- homeassistant/generated/zeroconf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index d031632ed75..7d84641874a 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["application_credentials", "webhook"], "documentation": "https://www.home-assistant.io/integrations/netatmo", "homekit": { - "models": ["Healty Home Coach", "Netatmo Relay", "Presence", "Welcome"] + "models": ["Healthy Home Coach", "Netatmo Relay", "Presence", "Welcome"] }, "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 485d16e46e7..d97bef19eb4 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -52,7 +52,7 @@ HOMEKIT = { "always_discover": True, "domain": "hive", }, - "Healty Home Coach": { + "Healthy Home Coach": { "always_discover": True, "domain": "netatmo", }, From a78764f000ccadb499a6bf2c34243031497b9490 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Nov 2023 14:57:37 +0100 Subject: [PATCH 554/982] Improve formatting of package errors (#104078) --- homeassistant/config.py | 23 ++++++++++------------- homeassistant/helpers/check_config.py | 2 +- tests/helpers/test_check_config.py | 4 ++-- tests/snapshots/test_config.ambr | 24 ++++++++++++------------ 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 027839ca656..ac68f03ff52 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -831,17 +831,11 @@ def _log_pkg_error( hass: HomeAssistant, package: str, component: str, config: dict, message: str ) -> None: """Log an error while merging packages.""" - message = f"Package {package} setup failed. {message}" + message_prefix = f"Setup of package '{package}'" + if annotation := find_annotation(config, [CONF_CORE, CONF_PACKAGES, package]): + message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" - pack_config = config[CONF_CORE][CONF_PACKAGES].get(package, config) - config_file = getattr(pack_config, "__config_file__", None) - if config_file: - config_file = _relpath(hass, config_file) - else: - config_file = "?" - message += f" (See {config_file}:{getattr(pack_config, '__line__', '?')})." - - _LOGGER.error(message) + _LOGGER.error("%s failed: %s", message_prefix, message) def _identify_config_schema(module: ComponentProtocol) -> str | None: @@ -985,7 +979,7 @@ async def merge_packages_config( pack_name, comp_name, config, - f"Integration {comp_name} cannot be merged. Expected a dict.", + f"integration '{comp_name}' cannot be merged, expected a dict", ) continue @@ -998,7 +992,10 @@ async def merge_packages_config( pack_name, comp_name, config, - f"Integration {comp_name} cannot be merged. Dict expected in main config.", + ( + f"integration '{comp_name}' cannot be merged, dict expected in " + "main config" + ), ) continue @@ -1009,7 +1006,7 @@ async def merge_packages_config( pack_name, comp_name, config, - f"Integration {comp_name} has duplicate key '{duplicate_key}'.", + f"integration '{comp_name}' has duplicate key '{duplicate_key}'", ) return config diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 09bdf10cf70..ea5e7218f1f 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -100,7 +100,7 @@ async def async_check_ha_config_file( # noqa: C901 message: str, ) -> None: """Handle errors from packages.""" - message = f"Package {package} setup failed. {message}" + message = f"Setup of package '{package}' failed: {message}" domain = f"homeassistant.packages.{package}.{component}" pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_warning(message, domain, pack_config) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 56df99bb032..1dd07bc66ac 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -379,8 +379,8 @@ async def test_package_invalid(hass: HomeAssistant) -> None: warning = CheckConfigError( ( - "Package p1 setup failed. Integration group cannot be merged. Expected a " - "dict." + "Setup of package 'p1' failed: integration 'group' cannot be merged" + ", expected a dict" ), "homeassistant.packages.p1.group", {"group": ["a"]}, diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index b65617cb649..785989ad839 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -312,38 +312,38 @@ # --- # name: test_package_merge_error[packages] list([ - 'Package pack_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See configuration.yaml:9).', - 'Package pack_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See configuration.yaml:13).', - "Package pack_4 setup failed. Integration adr_0007_3 has duplicate key 'host'. (See configuration.yaml:20).", - "Package pack_5 setup failed. Integration 'unknown_integration' not found. (See configuration.yaml:23).", + "Setup of package 'pack_1' at configuration.yaml, line 7 failed: integration 'adr_0007_1' cannot be merged, dict expected in main config", + "Setup of package 'pack_2' at configuration.yaml, line 11 failed: integration 'adr_0007_2' cannot be merged, expected a dict", + "Setup of package 'pack_4' at configuration.yaml, line 19 failed: integration 'adr_0007_3' has duplicate key 'host'", + "Setup of package 'pack_5' at configuration.yaml, line 22 failed: Integration 'unknown_integration' not found.", ]) # --- # name: test_package_merge_error[packages_include_dir_named] list([ - 'Package adr_0007_1 setup failed. Integration adr_0007_1 cannot be merged. Dict expected in main config. (See integrations/adr_0007_1.yaml:2).', - 'Package adr_0007_2 setup failed. Integration adr_0007_2 cannot be merged. Expected a dict. (See integrations/adr_0007_2.yaml:2).', - "Package adr_0007_3_2 setup failed. Integration adr_0007_3 has duplicate key 'host'. (See integrations/adr_0007_3_2.yaml:1).", - "Package unknown_integration setup failed. Integration 'unknown_integration' not found. (See integrations/unknown_integration.yaml:2).", + "Setup of package 'adr_0007_1' at integrations/adr_0007_1.yaml, line 2 failed: integration 'adr_0007_1' cannot be merged, dict expected in main config", + "Setup of package 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2 failed: integration 'adr_0007_2' cannot be merged, expected a dict", + "Setup of package 'adr_0007_3_2' at integrations/adr_0007_3_2.yaml, line 1 failed: integration 'adr_0007_3' has duplicate key 'host'", + "Setup of package 'unknown_integration' at integrations/unknown_integration.yaml, line 2 failed: Integration 'unknown_integration' not found.", ]) # --- # name: test_package_merge_exception[packages-error0] list([ - "Package pack_1 setup failed. Integration test_domain caused error: No such file or directory: b'liblibc.a' (See configuration.yaml:4).", + "Setup of package 'pack_1' at configuration.yaml, line 3 failed: Integration test_domain caused error: No such file or directory: b'liblibc.a'", ]) # --- # name: test_package_merge_exception[packages-error1] list([ - "Package pack_1 setup failed. Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something' (See configuration.yaml:4).", + "Setup of package 'pack_1' at configuration.yaml, line 3 failed: Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something'", ]) # --- # name: test_package_merge_exception[packages_include_dir_named-error0] list([ - "Package unknown_integration setup failed. Integration test_domain caused error: No such file or directory: b'liblibc.a' (See integrations/unknown_integration.yaml:1).", + "Setup of package 'unknown_integration' at integrations/unknown_integration.yaml, line 1 failed: Integration test_domain caused error: No such file or directory: b'liblibc.a'", ]) # --- # name: test_package_merge_exception[packages_include_dir_named-error1] list([ - "Package unknown_integration setup failed. Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something' (See integrations/unknown_integration.yaml:1).", + "Setup of package 'unknown_integration' at integrations/unknown_integration.yaml, line 1 failed: Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something'", ]) # --- # name: test_yaml_error[basic] From fd7f75e9afc60fc7bdac34de0ea051f79f58c22a Mon Sep 17 00:00:00 2001 From: Mark Coombes Date: Fri, 17 Nov 2023 08:59:17 -0500 Subject: [PATCH 555/982] Remove marthoc as ecobee code owner (#104053) * Update ecobee manifest.json Remove @marthoc as codeowner. * run hassfest --------- Co-authored-by: Charles Garwood --- CODEOWNERS | 4 ++-- homeassistant/components/ecobee/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index a28c164219a..bc889a6f535 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -307,8 +307,8 @@ build.json @home-assistant/supervisor /tests/components/eafm/ @Jc2k /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas -/homeassistant/components/ecobee/ @marthoc @marcolivierarsenault -/tests/components/ecobee/ @marthoc @marcolivierarsenault +/homeassistant/components/ecobee/ @marcolivierarsenault +/tests/components/ecobee/ @marcolivierarsenault /homeassistant/components/ecoforest/ @pjanuario /tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @w1ll1am23 diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index ffb7fe8adfe..1160cd946d9 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -1,7 +1,7 @@ { "domain": "ecobee", "name": "ecobee", - "codeowners": ["@marthoc", "@marcolivierarsenault"], + "codeowners": ["@marcolivierarsenault"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "homekit": { From 8c99cf14d37163780adbaa0174e38b32450b616c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 17 Nov 2023 11:07:08 -0600 Subject: [PATCH 556/982] Context slot decisions moved into hassil (#104132) --- .../components/conversation/default_agent.py | 11 ++--------- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/conversation/test_default_agent.py | 8 ++++---- 6 files changed, 13 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c1282bbbac1..99ebb4b60b1 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -224,18 +224,11 @@ class DefaultAgent(AbstractConversationAgent): # loaded in async_recognize. assert lang_intents is not None - # Include slot values from intent_context, such as the name of the - # device's area. + # Slot values to pass to the intent slots = { - entity_name: {"value": entity_value} - for entity_name, entity_value in result.context.items() + entity.name: {"value": entity.value} for entity in result.entities_list } - # Override context with result entities - slots.update( - {entity.name: {"value": entity.value} for entity in result.entities_list} - ) - try: intent_response = await intent.async_handle( self.hass, diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 418342f714d..e99334b5c37 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.0", "home-assistant-intents==2023.11.13"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.17"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 377b538eb86..99b6a6a8392 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,10 +25,10 @@ fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 -hassil==1.5.0 +hassil==1.5.1 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231030.2 -home-assistant-intents==2023.11.13 +home-assistant-intents==2023.11.17 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index d2b697df320..b0aff4e51f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -985,7 +985,7 @@ hass-nabucasa==0.74.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.5.0 +hassil==1.5.1 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -1018,7 +1018,7 @@ holidays==0.36 home-assistant-frontend==20231030.2 # homeassistant.components.conversation -home-assistant-intents==2023.11.13 +home-assistant-intents==2023.11.17 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae02f403f44..3d12c8688c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -778,7 +778,7 @@ habitipy==0.2.0 hass-nabucasa==0.74.0 # homeassistant.components.conversation -hassil==1.5.0 +hassil==1.5.1 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -802,7 +802,7 @@ holidays==0.36 home-assistant-frontend==20231030.2 # homeassistant.components.conversation -home-assistant-intents==2023.11.13 +home-assistant-intents==2023.11.17 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index bc85cdf604c..fe94e2d5425 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -343,10 +343,10 @@ async def test_device_area_context( ) device_registry.async_update_device(bedroom_satellite.id, area_id=area_bedroom.id) - # Turn on all lights in the area of a device + # Turn on lights in the area of a device result = await conversation.async_converse( hass, - "turn on all lights", + "turn on the lights", None, Context(), None, @@ -367,7 +367,7 @@ async def test_device_area_context( # Ensure we can still target other areas by name result = await conversation.async_converse( hass, - "turn on all lights in the bedroom", + "turn on lights in the bedroom", None, Context(), None, @@ -388,7 +388,7 @@ async def test_device_area_context( # Turn off all lights in the area of the otherkj device result = await conversation.async_converse( hass, - "turn all lights off", + "turn lights off", None, Context(), None, From c047f47595e5326318e85c793225b131fcf5e95e Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 17 Nov 2023 18:26:46 +0100 Subject: [PATCH 557/982] Fix ZHA covering mode for Aqara E1 curtain driver not initialized (#102749) --- .../zha/core/cluster_handlers/closures.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 980a6f88a75..16c7aef89ad 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -1,6 +1,9 @@ """Closures cluster handlers module for Zigbee Home Automation.""" -from typing import Any +from __future__ import annotations +from typing import TYPE_CHECKING, Any + +import zigpy.zcl from zigpy.zcl.clusters import closures from homeassistant.core import callback @@ -9,6 +12,9 @@ from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED from . import AttrReportConfig, ClientClusterHandler, ClusterHandler +if TYPE_CHECKING: + from ..endpoint import Endpoint + @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.DoorLock.cluster_id) class DoorLockClusterHandler(ClusterHandler): @@ -139,6 +145,14 @@ class WindowCovering(ClusterHandler): ), ) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize WindowCovering cluster handler.""" + super().__init__(cluster, endpoint) + + if self.cluster.endpoint.model == "lumi.curtain.agl001": + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["window_covering_mode"] = True + async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value( From e755dd83b62d6e7554d082b9d8ba60b7916013d2 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 17 Nov 2023 18:28:27 +0100 Subject: [PATCH 558/982] Change ZHA Tuya plugs to use quirk IDs (#102489) Change ZHA Tuya plug matches to use quirk IDs --- .../zha/core/cluster_handlers/general.py | 25 ++-------- .../cluster_handlers/manufacturerspecific.py | 21 +-------- homeassistant/components/zha/select.py | 47 ++----------------- homeassistant/components/zha/switch.py | 4 +- 4 files changed, 14 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 6ca4e420d5f..8bc6902b4ff 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Coroutine from typing import TYPE_CHECKING, Any +from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF import zigpy.exceptions import zigpy.types as t import zigpy.zcl @@ -347,26 +348,10 @@ class OnOffClusterHandler(ClusterHandler): super().__init__(cluster, endpoint) self._off_listener = None - if self.cluster.endpoint.model not in ( - "TS011F", - "TS0121", - "TS0001", - "TS0002", - "TS0003", - "TS0004", - ): - return - - try: - self.cluster.find_attribute("backlight_mode") - except KeyError: - return - - self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() - self.ZCL_INIT_ATTRS["backlight_mode"] = True - self.ZCL_INIT_ATTRS["power_on_state"] = True - - if self.cluster.endpoint.model == "TS011F": + if endpoint.device.quirk_id == TUYA_PLUG_ONOFF: + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["backlight_mode"] = True + self.ZCL_INIT_ATTRS["power_on_state"] = True self.ZCL_INIT_ATTRS["child_lock"] = True @classmethod diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index f2e5dafa099..493d7bca199 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING, Any from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER import zigpy.zcl from homeassistant.core import callback @@ -72,25 +73,7 @@ class TuyaClusterHandler(ClusterHandler): def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: """Initialize TuyaClusterHandler.""" super().__init__(cluster, endpoint) - - if self.cluster.endpoint.manufacturer in ( - "_TZE200_7tdtqgwv", - "_TZE200_amp6tsvy", - "_TZE200_oisqyl4o", - "_TZE200_vhy3iakz", - "_TZ3000_uim07oem", - "_TZE200_wfxuhoea", - "_TZE200_tviaymwx", - "_TZE200_g1ib5ldv", - "_TZE200_wunufsil", - "_TZE200_7deq70b8", - "_TZE200_tz32mtza", - "_TZE200_2hf7x9n3", - "_TZE200_aqnazj70", - "_TZE200_1ozguk6x", - "_TZE200_k6jhsr0q", - "_TZE200_9mahtqtg", - ): + if endpoint.device.quirk_id == TUYA_PLUG_MANUFACTURER: self.ZCL_INIT_ATTRS = { "backlight_mode": True, "power_on_state": True, diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 46089dd5a28..9ff3d2d9b6f 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -6,6 +6,7 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF from zigpy import types from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd @@ -246,29 +247,10 @@ class TuyaPowerOnState(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_ON_OFF, - models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF ) @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names="tuya_manufacturer", - manufacturers={ - "_TZE200_7tdtqgwv", - "_TZE200_amp6tsvy", - "_TZE200_oisqyl4o", - "_TZE200_vhy3iakz", - "_TZ3000_uim07oem", - "_TZE200_wfxuhoea", - "_TZE200_tviaymwx", - "_TZE200_g1ib5ldv", - "_TZE200_wunufsil", - "_TZE200_7deq70b8", - "_TZE200_tz32mtza", - "_TZE200_2hf7x9n3", - "_TZE200_aqnazj70", - "_TZE200_1ozguk6x", - "_TZE200_k6jhsr0q", - "_TZE200_9mahtqtg", - }, + cluster_handler_names="tuya_manufacturer", quirk_ids=TUYA_PLUG_MANUFACTURER ) class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA power on state select entity.""" @@ -288,8 +270,7 @@ class TuyaBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_ON_OFF, - models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF ) class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA backlight mode select entity.""" @@ -310,25 +291,7 @@ class MoesBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names="tuya_manufacturer", - manufacturers={ - "_TZE200_7tdtqgwv", - "_TZE200_amp6tsvy", - "_TZE200_oisqyl4o", - "_TZE200_vhy3iakz", - "_TZ3000_uim07oem", - "_TZE200_wfxuhoea", - "_TZE200_tviaymwx", - "_TZE200_g1ib5ldv", - "_TZE200_wunufsil", - "_TZE200_7deq70b8", - "_TZE200_tz32mtza", - "_TZE200_2hf7x9n3", - "_TZE200_aqnazj70", - "_TZE200_1ozguk6x", - "_TZE200_k6jhsr0q", - "_TZE200_9mahtqtg", - }, + cluster_handler_names="tuya_manufacturer", quirk_ids=TUYA_PLUG_MANUFACTURER ) class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity): """Moes devices have a different backlight mode select options.""" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index e49bc44b822..d14c13ab109 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -5,6 +5,7 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self +from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status @@ -488,8 +489,7 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_ON_OFF, - models={"TS011F"}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF ) class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): """Representation of a child lock configuration entity.""" From 483e0c9a8530fc00f862c727fba12178475bdb51 Mon Sep 17 00:00:00 2001 From: codyhackw <49957005+codyhackw@users.noreply.github.com> Date: Fri, 17 Nov 2023 12:38:58 -0500 Subject: [PATCH 559/982] Fix ZHA VZM35-SN attributes (#102924) * Fixes for VZM35-SN Attributes * Fixes * Update strings.json Was missing translation keys from strings.json * Minor Tweak to switch_type entity Editing main switch_type entity to ensure it doesn't cause an error for the VZM35-SN * Fix for Button Delay Entity --- .../cluster_handlers/manufacturerspecific.py | 131 ++++++++++++------ homeassistant/components/zha/number.py | 18 ++- homeassistant/components/zha/select.py | 51 ++++++- homeassistant/components/zha/strings.json | 9 ++ homeassistant/components/zha/switch.py | 11 ++ 5 files changed, 174 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 493d7bca199..99c1e954a0e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -224,49 +224,94 @@ class InovelliConfigEntityClusterHandler(ClusterHandler): """Inovelli Configuration Entity cluster handler.""" REPORT_CONFIG = () - ZCL_INIT_ATTRS = { - "dimming_speed_up_remote": True, - "dimming_speed_up_local": True, - "ramp_rate_off_to_on_local": True, - "ramp_rate_off_to_on_remote": True, - "dimming_speed_down_remote": True, - "dimming_speed_down_local": True, - "ramp_rate_on_to_off_local": True, - "ramp_rate_on_to_off_remote": True, - "minimum_level": True, - "maximum_level": True, - "invert_switch": True, - "auto_off_timer": True, - "default_level_local": True, - "default_level_remote": True, - "state_after_power_restored": True, - "load_level_indicator_timeout": True, - "active_power_reports": True, - "periodic_power_and_energy_reports": True, - "active_energy_reports": True, - "power_type": False, - "switch_type": False, - "increased_non_neutral_output": True, - "button_delay": False, - "smart_bulb_mode": False, - "double_tap_up_enabled": True, - "double_tap_down_enabled": True, - "double_tap_up_level": True, - "double_tap_down_level": True, - "led_color_when_on": True, - "led_color_when_off": True, - "led_intensity_when_on": True, - "led_intensity_when_off": True, - "led_scaling_mode": True, - "aux_switch_scenes": True, - "binding_off_to_on_sync_level": True, - "local_protection": False, - "output_mode": False, - "on_off_led_mode": True, - "firmware_progress_led": True, - "relay_click_in_on_off_mode": True, - "disable_clear_notifications_double_tap": True, - } + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Inovelli cluster handler.""" + super().__init__(cluster, endpoint) + if self.cluster.endpoint.model == "VZM31-SN": + self.ZCL_INIT_ATTRS = { + "dimming_speed_up_remote": True, + "dimming_speed_up_local": True, + "ramp_rate_off_to_on_local": True, + "ramp_rate_off_to_on_remote": True, + "dimming_speed_down_remote": True, + "dimming_speed_down_local": True, + "ramp_rate_on_to_off_local": True, + "ramp_rate_on_to_off_remote": True, + "minimum_level": True, + "maximum_level": True, + "invert_switch": True, + "auto_off_timer": True, + "default_level_local": True, + "default_level_remote": True, + "state_after_power_restored": True, + "load_level_indicator_timeout": True, + "active_power_reports": True, + "periodic_power_and_energy_reports": True, + "active_energy_reports": True, + "power_type": False, + "switch_type": False, + "increased_non_neutral_output": True, + "button_delay": False, + "smart_bulb_mode": False, + "double_tap_up_enabled": True, + "double_tap_down_enabled": True, + "double_tap_up_level": True, + "double_tap_down_level": True, + "led_color_when_on": True, + "led_color_when_off": True, + "led_intensity_when_on": True, + "led_intensity_when_off": True, + "led_scaling_mode": True, + "aux_switch_scenes": True, + "binding_off_to_on_sync_level": True, + "local_protection": False, + "output_mode": False, + "on_off_led_mode": True, + "firmware_progress_led": True, + "relay_click_in_on_off_mode": True, + "disable_clear_notifications_double_tap": True, + } + elif self.cluster.endpoint.model == "VZM35-SN": + self.ZCL_INIT_ATTRS = { + "dimming_speed_up_remote": True, + "dimming_speed_up_local": True, + "ramp_rate_off_to_on_local": True, + "ramp_rate_off_to_on_remote": True, + "dimming_speed_down_remote": True, + "dimming_speed_down_local": True, + "ramp_rate_on_to_off_local": True, + "ramp_rate_on_to_off_remote": True, + "minimum_level": True, + "maximum_level": True, + "invert_switch": True, + "auto_off_timer": True, + "default_level_local": True, + "default_level_remote": True, + "state_after_power_restored": True, + "load_level_indicator_timeout": True, + "power_type": False, + "switch_type": False, + "non_neutral_aux_med_gear_learn_value": True, + "non_neutral_aux_low_gear_learn_value": True, + "quick_start_time": False, + "button_delay": False, + "smart_fan_mode": False, + "double_tap_up_enabled": True, + "double_tap_down_enabled": True, + "double_tap_up_level": True, + "double_tap_down_level": True, + "led_color_when_on": True, + "led_color_when_off": True, + "led_intensity_when_on": True, + "led_intensity_when_off": True, + "aux_switch_scenes": True, + "local_protection": False, + "output_mode": False, + "on_off_led_mode": True, + "firmware_progress_led": True, + "smart_fan_led_display_levels": True, + } async def issue_all_led_effect( self, diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index ae2f9e0b758..53d79d2d35f 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -629,7 +629,7 @@ class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): class InovelliButtonDelay(ZHANumberConfigurationEntity): """Inovelli button delay configuration entity.""" - _unique_id_suffix = "dimming_speed_up_local" + _unique_id_suffix = "button_delay" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -778,6 +778,22 @@ class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): _attr_translation_key: str = "auto_off_timer" +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliQuickStartTime(ZHANumberConfigurationEntity): + """Inovelli fan quick start time configuration entity.""" + + _unique_id_suffix = "quick_start_time" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 10 + _attribute_name = "quick_start_time" + _attr_translation_key: str = "quick_start_time" + + @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 9ff3d2d9b6f..2ff8b7d36b9 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -447,7 +447,7 @@ class InovelliOutputModeEntity(ZCLEnumSelectEntity): class InovelliSwitchType(types.enum8): - """Inovelli output mode.""" + """Inovelli switch mode.""" Single_Pole = 0x00 Three_Way_Dumb = 0x01 @@ -456,7 +456,7 @@ class InovelliSwitchType(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM31-SN"} ) class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): """Inovelli switch type control.""" @@ -467,6 +467,25 @@ class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): _attr_translation_key: str = "switch_type" +class InovelliFanSwitchType(types.enum1): + """Inovelli fan switch mode.""" + + Load_Only = 0x00 + Three_Way_AUX = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliFanSwitchTypeEntity(ZCLEnumSelectEntity): + """Inovelli fan switch type control.""" + + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" + _enum = InovelliFanSwitchType + _attr_translation_key: str = "switch_type" + + class InovelliLedScalingMode(types.enum1): """Inovelli led mode.""" @@ -486,6 +505,34 @@ class InovelliLedScalingModeEntity(ZCLEnumSelectEntity): _attr_translation_key: str = "led_scaling_mode" +class InovelliFanLedScalingMode(types.enum8): + """Inovelli fan led mode.""" + + VZM31SN = 0x00 + Grade_1 = 0x01 + Grade_2 = 0x02 + Grade_3 = 0x03 + Grade_4 = 0x04 + Grade_5 = 0x05 + Grade_6 = 0x06 + Grade_7 = 0x07 + Grade_8 = 0x08 + Grade_9 = 0x09 + Adaptive = 0x0A + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliFanLedScalingModeEntity(ZCLEnumSelectEntity): + """Inovelli fan switch led mode control.""" + + _unique_id_suffix = "smart_fan_led_display_levels" + _attribute_name = "smart_fan_led_display_levels" + _enum = InovelliFanLedScalingMode + _attr_translation_key: str = "smart_fan_led_display_levels" + + class InovelliNonNeutralOutput(types.enum1): """Inovelli non neutral output selection.""" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 22c2810ad23..18bb3ae4f82 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -721,6 +721,9 @@ }, "away_preset_temperature": { "name": "Away preset temperature" + }, + "quick_start_time": { + "name": "Quick start time" } }, "select": { @@ -766,6 +769,9 @@ "led_scaling_mode": { "name": "Led scaling mode" }, + "smart_fan_led_display_levels": { + "name": "Smart fan led display levels" + }, "increased_non_neutral_output": { "name": "Non neutral output" }, @@ -878,6 +884,9 @@ "smart_bulb_mode": { "name": "Smart bulb mode" }, + "smart_fan_mode": { + "name": "Smart fan mode" + }, "double_tap_up_enabled": { "name": "Double tap up enabled" }, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index d14c13ab109..71c6e9d90ad 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -364,6 +364,17 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): _attr_translation_key = "smart_bulb_mode" +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliSmartFanMode(ZHASwitchConfigurationEntity): + """Inovelli smart fan mode control.""" + + _unique_id_suffix = "smart_fan_mode" + _attribute_name = "smart_fan_mode" + _attr_translation_key = "smart_fan_mode" + + @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) From 2387e4941b8fc6aff623e92a08de29c2a3b27b16 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Fri, 17 Nov 2023 18:17:16 +0000 Subject: [PATCH 560/982] Add constants to ring integration (#104134) --- homeassistant/components/ring/__init__.py | 58 +++++++++---------- .../components/ring/binary_sensor.py | 12 ++-- homeassistant/components/ring/camera.py | 8 +-- homeassistant/components/ring/config_flow.py | 28 +++++---- homeassistant/components/ring/const.py | 39 +++++++++++++ homeassistant/components/ring/diagnostics.py | 2 +- homeassistant/components/ring/entity.py | 10 +++- homeassistant/components/ring/light.py | 4 +- homeassistant/components/ring/sensor.py | 17 ++++-- homeassistant/components/ring/siren.py | 4 +- homeassistant/components/ring/switch.py | 4 +- 11 files changed, 120 insertions(+), 66 deletions(-) create mode 100644 homeassistant/components/ring/const.py diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 7e7bff1fa53..157a62df05b 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -11,32 +11,30 @@ from typing import Any import ring_doorbell from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform, __version__ +from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.async_ import run_callback_threadsafe +from .const import ( + DEVICES_SCAN_INTERVAL, + DOMAIN, + HEALTH_SCAN_INTERVAL, + HISTORY_SCAN_INTERVAL, + NOTIFICATIONS_SCAN_INTERVAL, + PLATFORMS, + RING_API, + RING_DEVICES, + RING_DEVICES_COORDINATOR, + RING_HEALTH_COORDINATOR, + RING_HISTORY_COORDINATOR, + RING_NOTIFICATIONS_COORDINATOR, +) + _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by Ring.com" - -NOTIFICATION_ID = "ring_notification" -NOTIFICATION_TITLE = "Ring Setup" - -DOMAIN = "ring" -DEFAULT_ENTITY_NAMESPACE = "ring" - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.LIGHT, - Platform.SENSOR, - Platform.SWITCH, - Platform.CAMERA, - Platform.SIREN, -] - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" @@ -48,12 +46,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: partial( hass.config_entries.async_update_entry, entry, - data={**entry.data, "token": token}, + data={**entry.data, CONF_TOKEN: token}, ), ).result() auth = ring_doorbell.Auth( - f"HomeAssistant/{__version__}", entry.data["token"], token_updater + f"{APPLICATION_NAME}/{__version__}", entry.data[CONF_TOKEN], token_updater ) ring = ring_doorbell.Ring(auth) @@ -64,34 +62,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed(err) from err hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "api": ring, - "devices": ring.devices(), - "device_data": GlobalDataUpdater( - hass, "device", entry, ring, "update_devices", timedelta(minutes=1) + RING_API: ring, + RING_DEVICES: ring.devices(), + RING_DEVICES_COORDINATOR: GlobalDataUpdater( + hass, "device", entry, ring, "update_devices", DEVICES_SCAN_INTERVAL ), - "dings_data": GlobalDataUpdater( + RING_NOTIFICATIONS_COORDINATOR: GlobalDataUpdater( hass, "active dings", entry, ring, "update_dings", - timedelta(seconds=5), + NOTIFICATIONS_SCAN_INTERVAL, ), - "history_data": DeviceDataUpdater( + RING_HISTORY_COORDINATOR: DeviceDataUpdater( hass, "history", entry, ring, lambda device: device.history(limit=10), - timedelta(minutes=1), + HISTORY_SCAN_INTERVAL, ), - "health_data": DeviceDataUpdater( + RING_HEALTH_COORDINATOR: DeviceDataUpdater( hass, "health", entry, ring, lambda device: device.update_health_data(), - timedelta(minutes=1), + HEALTH_SCAN_INTERVAL, ), } diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index ab7207f0ac4..05d26812f54 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN +from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR from .entity import RingEntityMixin @@ -53,8 +53,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ring binary sensors from a config entry.""" - ring = hass.data[DOMAIN][config_entry.entry_id]["api"] - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + ring = hass.data[DOMAIN][config_entry.entry_id][RING_API] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] entities = [ RingBinarySensor(config_entry.entry_id, ring, device, description) @@ -90,13 +90,15 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity): async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() - self.ring_objects["dings_data"].async_add_listener(self._dings_update_callback) + self.ring_objects[RING_NOTIFICATIONS_COORDINATOR].async_add_listener( + self._dings_update_callback + ) self._dings_update_callback() async def async_will_remove_from_hass(self) -> None: """Disconnect callbacks.""" await super().async_will_remove_from_hass() - self.ring_objects["dings_data"].async_remove_listener( + self.ring_objects[RING_NOTIFICATIONS_COORDINATOR].async_remove_listener( self._dings_update_callback ) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 7f897d17203..196d34600d1 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import DOMAIN +from .const import DOMAIN, RING_DEVICES, RING_HISTORY_COORDINATOR from .entity import RingEntityMixin FORCE_REFRESH_INTERVAL = timedelta(minutes=3) @@ -30,7 +30,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) cams = [] @@ -66,7 +66,7 @@ class RingCam(RingEntityMixin, Camera): """Register callbacks.""" await super().async_added_to_hass() - await self.ring_objects["history_data"].async_track_device( + await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( self._device, self._history_update_callback ) @@ -74,7 +74,7 @@ class RingCam(RingEntityMixin, Camera): """Disconnect callbacks.""" await super().async_will_remove_from_hass() - self.ring_objects["history_data"].async_untrack_device( + self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( self._device, self._history_update_callback ) diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 222a18fa24f..5c735a3ee8c 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -8,10 +8,16 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, __version__ as ha_version +from homeassistant.const import ( + APPLICATION_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + __version__ as ha_version, +) from homeassistant.data_entry_flow import FlowResult -from . import DOMAIN +from .const import CONF_2FA, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -24,14 +30,14 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect.""" - auth = ring_doorbell.Auth(f"HomeAssistant/{ha_version}") + auth = ring_doorbell.Auth(f"{APPLICATION_NAME}/{ha_version}") try: token = await hass.async_add_executor_job( auth.fetch_token, - data["username"], - data["password"], - data.get("2fa"), + data[CONF_USERNAME], + data[CONF_PASSWORD], + data.get(CONF_2FA), ) except ring_doorbell.Requires2FAError as err: raise Require2FA from err @@ -65,10 +71,10 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input["username"]) + await self.async_set_unique_id(user_input[CONF_USERNAME]) return self.async_create_entry( - title=user_input["username"], - data={"username": user_input["username"], "token": token}, + title=user_input[CONF_USERNAME], + data={CONF_USERNAME: user_input[CONF_USERNAME], CONF_TOKEN: token}, ) return self.async_show_form( @@ -87,7 +93,7 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="2fa", - data_schema=vol.Schema({vol.Required("2fa"): str}), + data_schema=vol.Schema({vol.Required(CONF_2FA): str}), ) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: @@ -119,7 +125,7 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: data = { CONF_USERNAME: user_input[CONF_USERNAME], - "token": token, + CONF_TOKEN: token, } self.hass.config_entries.async_update_entry( self.reauth_entry, data=data diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py new file mode 100644 index 00000000000..10d517ab4a3 --- /dev/null +++ b/homeassistant/components/ring/const.py @@ -0,0 +1,39 @@ +"""The Ring constants.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.const import Platform + +ATTRIBUTION = "Data provided by Ring.com" + +NOTIFICATION_ID = "ring_notification" +NOTIFICATION_TITLE = "Ring Setup" + +DOMAIN = "ring" +DEFAULT_ENTITY_NAMESPACE = "ring" + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, + Platform.CAMERA, + Platform.SIREN, +] + + +DEVICES_SCAN_INTERVAL = timedelta(minutes=1) +NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5) +HISTORY_SCAN_INTERVAL = timedelta(minutes=1) +HEALTH_SCAN_INTERVAL = timedelta(minutes=1) + +RING_API = "api" +RING_DEVICES = "devices" + +RING_DEVICES_COORDINATOR = "device_data" +RING_NOTIFICATIONS_COORDINATOR = "dings_data" +RING_HISTORY_COORDINATOR = "history_data" +RING_HEALTH_COORDINATOR = "health_data" + +CONF_2FA = "2fa" diff --git a/homeassistant/components/ring/diagnostics.py b/homeassistant/components/ring/diagnostics.py index f9624a76333..105800f8d13 100644 --- a/homeassistant/components/ring/diagnostics.py +++ b/homeassistant/components/ring/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import DOMAIN +from .const import DOMAIN TO_REDACT = { "id", diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 7160d2ef725..4896ea2db8b 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -3,7 +3,7 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from . import ATTRIBUTION, DOMAIN +from .const import ATTRIBUTION, DOMAIN, RING_DEVICES_COORDINATOR class RingEntityMixin(Entity): @@ -28,11 +28,15 @@ class RingEntityMixin(Entity): async def async_added_to_hass(self) -> None: """Register callbacks.""" - self.ring_objects["device_data"].async_add_listener(self._update_callback) + self.ring_objects[RING_DEVICES_COORDINATOR].async_add_listener( + self._update_callback + ) async def async_will_remove_from_hass(self) -> None: """Disconnect callbacks.""" - self.ring_objects["device_data"].async_remove_listener(self._update_callback) + self.ring_objects[RING_DEVICES_COORDINATOR].async_remove_listener( + self._update_callback + ) @callback def _update_callback(self) -> None: diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 93640e2764e..7830b2547a5 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import DOMAIN +from .const import DOMAIN, RING_DEVICES from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -34,7 +34,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the lights for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] lights = [] diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index af23af07eba..465f6196689 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -14,7 +14,12 @@ from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN +from .const import ( + DOMAIN, + RING_DEVICES, + RING_HEALTH_COORDINATOR, + RING_HISTORY_COORDINATOR, +) from .entity import RingEntityMixin @@ -24,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a sensor for a Ring device.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] entities = [ description.cls(config_entry.entry_id, device, description) @@ -75,7 +80,7 @@ class HealthDataRingSensor(RingSensor): """Register callbacks.""" await super().async_added_to_hass() - await self.ring_objects["health_data"].async_track_device( + await self.ring_objects[RING_HEALTH_COORDINATOR].async_track_device( self._device, self._health_update_callback ) @@ -83,7 +88,7 @@ class HealthDataRingSensor(RingSensor): """Disconnect callbacks.""" await super().async_will_remove_from_hass() - self.ring_objects["health_data"].async_untrack_device( + self.ring_objects[RING_HEALTH_COORDINATOR].async_untrack_device( self._device, self._health_update_callback ) @@ -112,7 +117,7 @@ class HistoryRingSensor(RingSensor): """Register callbacks.""" await super().async_added_to_hass() - await self.ring_objects["history_data"].async_track_device( + await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( self._device, self._history_update_callback ) @@ -120,7 +125,7 @@ class HistoryRingSensor(RingSensor): """Disconnect callbacks.""" await super().async_will_remove_from_hass() - self.ring_objects["history_data"].async_untrack_device( + self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( self._device, self._history_update_callback ) diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 7f1b147471d..7daf7bd69ca 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN +from .const import DOMAIN, RING_DEVICES from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -21,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the sirens for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] sirens = [] for device in devices["chimes"]: diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 7069acd5f0f..074dfee9bd6 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import DOMAIN +from .const import DOMAIN, RING_DEVICES from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the switches for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] switches = [] for device in devices["stickup_cams"]: From e2f6fbd59b4f7f2bdd20617fb65fce5edf90a4df Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Nov 2023 19:21:04 +0100 Subject: [PATCH 561/982] Fix colors in check_config script (#104069) --- homeassistant/scripts/check_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 25922ab1f81..0e00d0b75f2 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -290,13 +290,13 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): for key, value in sorted(layer.items(), key=sort_dict_key): if isinstance(value, (dict, list)): print(indent_str, str(key) + ":", line_info(value, **kwargs)) - dump_dict(value, indent_count + 2) + dump_dict(value, indent_count + 2, **kwargs) else: - print(indent_str, str(key) + ":", value) + print(indent_str, str(key) + ":", value, line_info(key, **kwargs)) indent_str = indent_count * " " if isinstance(layer, Sequence): for i in layer: if isinstance(i, dict): - dump_dict(i, indent_count + 2, True) + dump_dict(i, indent_count + 2, True, **kwargs) else: print(" ", indent_str, i) From 2d891c77ef5edf9c1f28fb58285e2b64ea39c319 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 17 Nov 2023 19:31:29 +0100 Subject: [PATCH 562/982] Reduce nesting in discovergy setup (#104127) * Reduce nesting in discovergy setup * Update homeassistant/components/discovergy/sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/discovergy/sensor.py | 43 ++++++------ .../discovergy/snapshots/test_sensor.ambr | 68 +++++++++++++++++++ tests/components/discovergy/test_sensor.py | 4 ++ 3 files changed, 94 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index c0c610fa98a..508af900a1c 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -30,12 +30,19 @@ from .const import DOMAIN, MANUFACTURER PARALLEL_UPDATES = 1 +def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None: + """Get a value from a Reading and divide with scale it.""" + if (value := reading.values.get(key)) is not None: + return value / scale + return None + + @dataclass(kw_only=True) class DiscovergySensorEntityDescription(SensorEntityDescription): """Class to describe a Discovergy sensor entity.""" value_fn: Callable[[Reading, str, int], datetime | float | None] = field( - default=lambda reading, key, scale: float(reading.values[key] / scale) + default=_get_and_scale ) alternative_keys: list[str] = field(default_factory=lambda: []) scale: int = field(default_factory=lambda: 1000) @@ -165,33 +172,27 @@ async def async_setup_entry( entities: list[DiscovergySensor] = [] for meter in meters: - sensors = None - if meter.measurement_type == "ELECTRICITY": - sensors = ELECTRICITY_SENSORS - elif meter.measurement_type == "GAS": - sensors = GAS_SENSORS - + sensors: tuple[DiscovergySensorEntityDescription, ...] = () coordinator: DiscovergyUpdateCoordinator = data.coordinators[meter.meter_id] - if sensors is not None: - for description in sensors: - # check if this meter has this data, then add this sensor - for key in {description.key, *description.alternative_keys}: - if key in coordinator.data.values: - entities.append( - DiscovergySensor(key, description, meter, coordinator) - ) + # select sensor descriptions based on meter type and combine with additional sensors + if meter.measurement_type == "ELECTRICITY": + sensors = ELECTRICITY_SENSORS + ADDITIONAL_SENSORS + elif meter.measurement_type == "GAS": + sensors = GAS_SENSORS + ADDITIONAL_SENSORS - for description in ADDITIONAL_SENSORS: - entities.append( - DiscovergySensor(description.key, description, meter, coordinator) - ) + entities.extend( + DiscovergySensor(value_key, description, meter, coordinator) + for description in sensors + for value_key in {description.key, *description.alternative_keys} + if description.value_fn(coordinator.data, value_key, description.scale) + ) - async_add_entities(entities, False) + async_add_entities(entities) class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEntity): - """Represents a discovergy smart meter sensor.""" + """Represents a Discovergy smart meter sensor.""" entity_description: DiscovergySensorEntityDescription data_key: str diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr index 36af1276fe1..981d1119a93 100644 --- a/tests/components/discovergy/snapshots/test_sensor.ambr +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -1,4 +1,38 @@ # serializer version: 1 +# name: test_sensor[electricity last transmitted] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.electricity_teststrasse_1_last_transmitted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last transmitted', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_transmitted', + 'unique_id': 'abc123-last_transmitted', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[electricity last transmitted].1 + None +# --- # name: test_sensor[electricity total consumption] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -101,6 +135,40 @@ 'state': '531.75', }) # --- +# name: test_sensor[gas last transmitted] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gas_teststrasse_1_last_transmitted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last transmitted', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_transmitted', + 'unique_id': 'def456-last_transmitted', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[gas last transmitted].1 + None +# --- # name: test_sensor[gas total consumption] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/discovergy/test_sensor.py b/tests/components/discovergy/test_sensor.py index 33fb1a37cd9..aba8229acf5 100644 --- a/tests/components/discovergy/test_sensor.py +++ b/tests/components/discovergy/test_sensor.py @@ -16,12 +16,16 @@ from homeassistant.helpers import entity_registry as er [ "sensor.electricity_teststrasse_1_total_consumption", "sensor.electricity_teststrasse_1_total_power", + "sensor.electricity_teststrasse_1_last_transmitted", "sensor.gas_teststrasse_1_total_gas_consumption", + "sensor.gas_teststrasse_1_last_transmitted", ], ids=[ "electricity total consumption", "electricity total power", + "electricity last transmitted", "gas total consumption", + "gas last transmitted", ], ) @pytest.mark.usefixtures("setup_integration") From e5bc25523ed7dfc523a2fe62dc010934105c69a9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 17 Nov 2023 20:30:30 +0100 Subject: [PATCH 563/982] Add config flow for Ping (#103743) --- homeassistant/components/ping/__init__.py | 26 +- .../components/ping/binary_sensor.py | 73 ++++-- homeassistant/components/ping/config_flow.py | 107 +++++++++ homeassistant/components/ping/const.py | 6 +- .../components/ping/device_tracker.py | 226 ++++++++---------- homeassistant/components/ping/helpers.py | 8 +- homeassistant/components/ping/manifest.json | 1 + homeassistant/components/ping/services.yaml | 1 - homeassistant/components/ping/strings.json | 31 ++- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/ping/conftest.py | 14 ++ tests/components/ping/const.py | 11 + tests/components/ping/test_binary_sensor.py | 55 ----- tests/components/ping/test_config_flow.py | 122 ++++++++++ 15 files changed, 473 insertions(+), 211 deletions(-) create mode 100644 homeassistant/components/ping/config_flow.py delete mode 100644 homeassistant/components/ping/services.yaml create mode 100644 tests/components/ping/conftest.py create mode 100644 tests/components/ping/const.py delete mode 100644 tests/components/ping/test_binary_sensor.py create mode 100644 tests/components/ping/test_config_flow.py diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index df1f7ebc9e5..3280173813d 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -6,16 +6,18 @@ import logging from icmplib import SocketPermissionError, async_ping +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) +PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] @dataclass(slots=True) @@ -27,7 +29,6 @@ class PingDomainData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) hass.data[DOMAIN] = PingDomainData( privileged=await _can_use_icmp_lib_with_privilege(), @@ -36,6 +37,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ping (ICMP) from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + async def _can_use_icmp_lib_with_privilege() -> None | bool: """Verify we can create a raw socket.""" try: diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index b120c453195..f81a6b7d22d 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -12,30 +12,26 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import PingDomainData -from .const import DOMAIN +from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) - ATTR_ROUND_TRIP_TIME_AVG = "round_trip_time_avg" ATTR_ROUND_TRIP_TIME_MAX = "round_trip_time_max" ATTR_ROUND_TRIP_TIME_MDEV = "round_trip_time_mdev" ATTR_ROUND_TRIP_TIME_MIN = "round_trip_time_min" -CONF_PING_COUNT = "count" - -DEFAULT_NAME = "Ping" -DEFAULT_PING_COUNT = 5 - SCAN_INTERVAL = timedelta(minutes=5) PARALLEL_UPDATES = 50 @@ -57,22 +53,49 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Ping Binary sensor.""" + """YAML init: import via config flow.""" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_IMPORTED_BY: "binary_sensor", **config}, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a Ping config entry.""" data: PingDomainData = hass.data[DOMAIN] - host: str = config[CONF_HOST] - count: int = config[CONF_PING_COUNT] - name: str = config.get(CONF_NAME, f"{DEFAULT_NAME} {host}") - privileged: bool | None = data.privileged + host: str = entry.options[CONF_HOST] + count: int = int(entry.options[CONF_PING_COUNT]) ping_cls: type[PingDataSubProcess | PingDataICMPLib] - if privileged is None: + if data.privileged is None: ping_cls = PingDataSubProcess else: ping_cls = PingDataICMPLib async_add_entities( - [PingBinarySensor(name, ping_cls(hass, host, count, privileged))] + [PingBinarySensor(entry, ping_cls(hass, host, count, data.privileged))] ) @@ -80,12 +103,24 @@ class PingBinarySensor(RestoreEntity, BinarySensorEntity): """Representation of a Ping Binary sensor.""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_available = False - def __init__(self, name: str, ping: PingDataSubProcess | PingDataICMPLib) -> None: + def __init__( + self, + config_entry: ConfigEntry, + ping_cls: PingDataSubProcess | PingDataICMPLib, + ) -> None: """Initialize the Ping Binary sensor.""" - self._attr_available = False - self._attr_name = name - self._ping = ping + self._attr_name = config_entry.title + self._attr_unique_id = config_entry.entry_id + + # if this was imported just enable it when it was enabled before + if CONF_IMPORTED_BY in config_entry.data: + self._attr_entity_registry_enabled_default = bool( + config_entry.data[CONF_IMPORTED_BY] == "binary_sensor" + ) + + self._ping = ping_cls @property def is_on(self) -> bool: diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py new file mode 100644 index 00000000000..42cdd3f3a77 --- /dev/null +++ b/homeassistant/components/ping/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for Ping (ICMP) integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector +from homeassistant.util.network import is_ip_address + +from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ping.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + } + ), + ) + + if not is_ip_address(user_input[CONF_HOST]): + self.async_abort(reason="invalid_ip_address") + + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + return self.async_create_entry( + title=user_input[CONF_HOST], + data={}, + options={**user_input, CONF_PING_COUNT: DEFAULT_PING_COUNT}, + ) + + async def async_step_import(self, import_info: Mapping[str, Any]) -> FlowResult: + """Import an entry.""" + + to_import = { + CONF_HOST: import_info[CONF_HOST], + CONF_PING_COUNT: import_info[CONF_PING_COUNT], + } + title = import_info.get(CONF_NAME, import_info[CONF_HOST]) + + self._async_abort_entries_match({CONF_HOST: to_import[CONF_HOST]}) + return self.async_create_entry( + title=title, + data={CONF_IMPORTED_BY: import_info[CONF_IMPORTED_BY]}, + options=to_import, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle an options flow for Ping.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=self.config_entry.options[CONF_HOST] + ): str, + vol.Optional( + CONF_PING_COUNT, + default=self.config_entry.options[CONF_PING_COUNT], + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=100, mode=selector.NumberSelectorMode.BOX + ) + ), + } + ), + ) diff --git a/homeassistant/components/ping/const.py b/homeassistant/components/ping/const.py index fd70a9340c2..6ee53ea3d22 100644 --- a/homeassistant/components/ping/const.py +++ b/homeassistant/components/ping/const.py @@ -1,6 +1,5 @@ """Tracks devices by sending a ICMP echo request (ping).""" -from homeassistant.const import Platform # The ping binary and icmplib timeouts are not the same # timeout. ping is an overall timeout, icmplib is the @@ -15,4 +14,7 @@ ICMP_TIMEOUT = 1 PING_ATTEMPTS_COUNT = 3 DOMAIN = "ping" -PLATFORMS = [Platform.BINARY_SENSOR] + +CONF_PING_COUNT = "count" +CONF_IMPORTED_BY = "imported_by" +DEFAULT_PING_COUNT = 5 diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 9a63a2f844d..af07325db00 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -1,38 +1,33 @@ """Tracks devices by sending a ICMP echo request (ping).""" from __future__ import annotations -import asyncio -from datetime import datetime, timedelta +from datetime import timedelta import logging -import subprocess -from icmplib import async_multiping import voluptuous as vol from homeassistant.components.device_tracker import ( - CONF_SCAN_INTERVAL, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, - SCAN_INTERVAL, AsyncSeeCallback, + ScannerEntity, SourceType, ) -from homeassistant.const import CONF_HOSTS -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util -from homeassistant.util.async_ import gather_with_limited_concurrency -from homeassistant.util.process import kill_subprocess from . import PingDomainData -from .const import DOMAIN, ICMP_TIMEOUT, PING_ATTEMPTS_COUNT, PING_TIMEOUT +from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN +from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -CONF_PING_COUNT = "count" -CONCURRENT_PING_LIMIT = 6 +SCAN_INTERVAL = timedelta(minutes=5) PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( { @@ -42,123 +37,110 @@ PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( ) -class HostSubProcess: - """Host object with ping detection.""" - - def __init__( - self, - ip_address: str, - dev_id: str, - hass: HomeAssistant, - config: ConfigType, - privileged: bool | None, - ) -> None: - """Initialize the Host pinger.""" - self.hass = hass - self.ip_address = ip_address - self.dev_id = dev_id - self._count = config[CONF_PING_COUNT] - self._ping_cmd = ["ping", "-n", "-q", "-c1", "-W1", ip_address] - - def ping(self) -> bool | None: - """Send an ICMP echo request and return True if success.""" - with subprocess.Popen( - self._ping_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - close_fds=False, # required for posix_spawn - ) as pinger: - try: - pinger.communicate(timeout=1 + PING_TIMEOUT) - return pinger.returncode == 0 - except subprocess.TimeoutExpired: - kill_subprocess(pinger) - return False - - except subprocess.CalledProcessError: - return False - - def update(self) -> bool: - """Update device state by sending one or more ping messages.""" - failed = 0 - while failed < self._count: # check more times if host is unreachable - if self.ping(): - return True - failed += 1 - - _LOGGER.debug("No response from %s failed=%d", self.ip_address, failed) - return False - - async def async_setup_scanner( hass: HomeAssistant, config: ConfigType, async_see: AsyncSeeCallback, discovery_info: DiscoveryInfoType | None = None, ) -> bool: - """Set up the Host objects and return the update function.""" + """Legacy init: import via config flow.""" + + for dev_name, dev_host in config[CONF_HOSTS].items(): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_IMPORTED_BY: "device_tracker", + CONF_NAME: dev_name, + CONF_HOST: dev_host, + CONF_PING_COUNT: config[CONF_PING_COUNT], + }, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a Ping config entry.""" data: PingDomainData = hass.data[DOMAIN] - privileged = data.privileged - ip_to_dev_id = {ip: dev_id for (dev_id, ip) in config[CONF_HOSTS].items()} - interval = config.get( - CONF_SCAN_INTERVAL, - timedelta(seconds=len(ip_to_dev_id) * config[CONF_PING_COUNT]) + SCAN_INTERVAL, - ) - _LOGGER.debug( - "Started ping tracker with interval=%s on hosts: %s", - interval, - ",".join(ip_to_dev_id.keys()), - ) - - if privileged is None: - hosts = [ - HostSubProcess(ip, dev_id, hass, config, privileged) - for (dev_id, ip) in config[CONF_HOSTS].items() - ] - - async def async_update(now: datetime) -> None: - """Update all the hosts on every interval time.""" - results = await gather_with_limited_concurrency( - CONCURRENT_PING_LIMIT, - *(hass.async_add_executor_job(host.update) for host in hosts), - ) - await asyncio.gather( - *( - async_see(dev_id=host.dev_id, source_type=SourceType.ROUTER) - for idx, host in enumerate(hosts) - if results[idx] - ) - ) - + host: str = entry.options[CONF_HOST] + count: int = int(entry.options[CONF_PING_COUNT]) + ping_cls: type[PingDataSubProcess | PingDataICMPLib] + if data.privileged is None: + ping_cls = PingDataSubProcess else: + ping_cls = PingDataICMPLib - async def async_update(now: datetime) -> None: - """Update all the hosts on every interval time.""" - responses = await async_multiping( - list(ip_to_dev_id), - count=PING_ATTEMPTS_COUNT, - timeout=ICMP_TIMEOUT, - privileged=privileged, - ) - _LOGGER.debug("Multiping responses: %s", responses) - await asyncio.gather( - *( - async_see(dev_id=dev_id, source_type=SourceType.ROUTER) - for idx, dev_id in enumerate(ip_to_dev_id.values()) - if responses[idx].is_alive - ) - ) + async_add_entities( + [PingDeviceTracker(entry, ping_cls(hass, host, count, data.privileged))] + ) - async def _async_update_interval(now: datetime) -> None: - try: - await async_update(now) - finally: - if not hass.is_stopping: - async_track_point_in_utc_time( - hass, _async_update_interval, now + interval - ) - await _async_update_interval(dt_util.now()) - return True +class PingDeviceTracker(ScannerEntity): + """Representation of a Ping device tracker.""" + + ping: PingDataSubProcess | PingDataICMPLib + + def __init__( + self, + config_entry: ConfigEntry, + ping_cls: PingDataSubProcess | PingDataICMPLib, + ) -> None: + """Initialize the Ping device tracker.""" + super().__init__() + + self._attr_name = config_entry.title + self.ping = ping_cls + self.config_entry = config_entry + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self.ping.ip_address + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self.config_entry.entry_id + + @property + def source_type(self) -> SourceType: + """Return the source type which is router.""" + return SourceType.ROUTER + + @property + def is_connected(self) -> bool: + """Return true if ping returns is_alive.""" + return self.ping.is_alive + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if entity is enabled by default.""" + if CONF_IMPORTED_BY in self.config_entry.data: + return bool(self.config_entry.data[CONF_IMPORTED_BY] == "device_tracker") + return False + + async def async_update(self) -> None: + """Update the sensor.""" + await self.ping.async_update() diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index da58858a801..ce3d5c3b461 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -33,7 +33,7 @@ class PingData: def __init__(self, hass: HomeAssistant, host: str, count: int) -> None: """Initialize the data object.""" self.hass = hass - self._ip_address = host + self.ip_address = host self._count = count @@ -49,10 +49,10 @@ class PingDataICMPLib(PingData): async def async_update(self) -> None: """Retrieve the latest details from the host.""" - _LOGGER.debug("ping address: %s", self._ip_address) + _LOGGER.debug("ping address: %s", self.ip_address) try: data = await async_ping( - self._ip_address, + self.ip_address, count=self._count, timeout=ICMP_TIMEOUT, privileged=self._privileged, @@ -89,7 +89,7 @@ class PingDataSubProcess(PingData): "-c", str(self._count), "-W1", - self._ip_address, + self.ip_address, ] async def async_ping(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/ping/manifest.json b/homeassistant/components/ping/manifest.json index e27c3a239d0..ded5a3fd3e6 100644 --- a/homeassistant/components/ping/manifest.json +++ b/homeassistant/components/ping/manifest.json @@ -2,6 +2,7 @@ "domain": "ping", "name": "Ping (ICMP)", "codeowners": ["@jpbede"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ping", "iot_class": "local_polling", "loggers": ["icmplib"], diff --git a/homeassistant/components/ping/services.yaml b/homeassistant/components/ping/services.yaml deleted file mode 100644 index c983a105c93..00000000000 --- a/homeassistant/components/ping/services.yaml +++ /dev/null @@ -1 +0,0 @@ -reload: diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 5b5c5da46bc..31441df7736 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -1,8 +1,31 @@ { - "services": { - "reload": { - "name": "[%key:common::action::reload%]", - "description": "Reloads ping sensors from the YAML-configuration." + "config": { + "step": { + "user": { + "title": "Add Ping", + "description": "Ping allows you to check the availability of a host.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "count": "Ping count" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_ip_address": "Invalid IP address." + } + }, + "options": { + "step": { + "init": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "count": "[%key:component::ping::config::step::user::data::count%]" + } + } + }, + "abort": { + "invalid_ip_address": "[%key:component::ping::config::abort::invalid_ip_address%]" } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 16f0e48e4ee..9c77bd753f8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -355,6 +355,7 @@ FLOWS = { "philips_js", "pi_hole", "picnic", + "ping", "plaato", "plex", "plugwise", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f59312073a6..228cc6fa5f5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4291,7 +4291,7 @@ "ping": { "name": "Ping (ICMP)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "pioneer": { diff --git a/tests/components/ping/conftest.py b/tests/components/ping/conftest.py new file mode 100644 index 00000000000..ded562b81d6 --- /dev/null +++ b/tests/components/ping/conftest.py @@ -0,0 +1,14 @@ +"""Test configuration for ping.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def patch_setup(*args, **kwargs): + """Patch setup methods.""" + with patch( + "homeassistant.components.ping.async_setup_entry", + return_value=True, + ), patch("homeassistant.components.ping.async_setup", return_value=True): + yield diff --git a/tests/components/ping/const.py b/tests/components/ping/const.py new file mode 100644 index 00000000000..cf002dc7ca6 --- /dev/null +++ b/tests/components/ping/const.py @@ -0,0 +1,11 @@ +"""Constants for tests.""" +from icmplib import Host + +BINARY_SENSOR_IMPORT_DATA = { + "name": "test2", + "host": "127.0.0.1", + "count": 1, + "scan_interval": 50, +} + +NON_AVAILABLE_HOST_PING = Host("192.168.178.1", 10, []) diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py deleted file mode 100644 index b9bdc917e70..00000000000 --- a/tests/components/ping/test_binary_sensor.py +++ /dev/null @@ -1,55 +0,0 @@ -"""The test for the ping binary_sensor platform.""" -from unittest.mock import patch - -import pytest - -from homeassistant import config as hass_config, setup -from homeassistant.components.ping import DOMAIN -from homeassistant.const import SERVICE_RELOAD -from homeassistant.core import HomeAssistant - -from tests.common import get_fixture_path - - -@pytest.fixture -def mock_ping() -> None: - """Mock icmplib.ping.""" - with patch("homeassistant.components.ping.async_ping"): - yield - - -async def test_reload(hass: HomeAssistant, mock_ping: None) -> None: - """Verify we can reload trend sensors.""" - - await setup.async_setup_component( - hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "ping", - "name": "test", - "host": "127.0.0.1", - "count": 1, - } - }, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 1 - - assert hass.states.get("binary_sensor.test") - - yaml_path = get_fixture_path("configuration.yaml", "ping") - with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 1 - - assert hass.states.get("binary_sensor.test") is None - assert hass.states.get("binary_sensor.test2") diff --git a/tests/components/ping/test_config_flow.py b/tests/components/ping/test_config_flow.py new file mode 100644 index 00000000000..6fff4ae7c71 --- /dev/null +++ b/tests/components/ping/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the Ping (ICMP) config flow.""" +from __future__ import annotations + +import pytest + +from homeassistant import config_entries +from homeassistant.components.ping import DOMAIN +from homeassistant.components.ping.const import CONF_IMPORTED_BY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import BINARY_SENSOR_IMPORT_DATA + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("host", "expected_title"), + (("192.618.178.1", "192.618.178.1"),), +) +@pytest.mark.usefixtures("patch_setup") +async def test_form(hass: HomeAssistant, host, expected_title) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": host, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title + assert result["data"] == {} + assert result["options"] == { + "count": 5, + "host": host, + } + + +@pytest.mark.parametrize( + ("host", "count", "expected_title"), + (("192.618.178.1", 10, "192.618.178.1"),), +) +@pytest.mark.usefixtures("patch_setup") +async def test_options(hass: HomeAssistant, host, count, expected_title) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + version=1, + source=config_entries.SOURCE_USER, + data={}, + domain=DOMAIN, + options={"count": count, "host": host}, + title=expected_title, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + "host": "10.10.10.1", + "count": count, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "count": count, + "host": "10.10.10.1", + } + + +@pytest.mark.usefixtures("patch_setup") +async def test_step_import(hass: HomeAssistant) -> None: + """Test for import step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_IMPORTED_BY: "binary_sensor", **BINARY_SENSOR_IMPORT_DATA}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test2" + assert result["data"] == {CONF_IMPORTED_BY: "binary_sensor"} + assert result["options"] == { + "host": "127.0.0.1", + "count": 1, + } + + # test import without name + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_IMPORTED_BY: "binary_sensor", "host": "10.10.10.10", "count": 5}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "10.10.10.10" + assert result["data"] == {CONF_IMPORTED_BY: "binary_sensor"} + assert result["options"] == { + "host": "10.10.10.10", + "count": 5, + } From 23ef97f7745b8594bf6f264483073027a1a89159 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Nov 2023 23:00:23 +0100 Subject: [PATCH 564/982] Use relative paths in yaml syntax error messages (#104084) --- homeassistant/config.py | 25 +++++++++++++++++++------ tests/snapshots/test_config.ambr | 10 +++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index ac68f03ff52..27b7ad09bd0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -18,6 +18,7 @@ from urllib.parse import urlparse from awesomeversion import AwesomeVersion import voluptuous as vol from voluptuous.humanize import MAX_VALIDATION_ERROR_ITEM_LENGTH +from yaml.error import MarkedYAMLError from . import auth from .auth import mfa_modules as auth_mfa_modules, providers as auth_providers @@ -393,12 +394,24 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: secrets = Secrets(Path(hass.config.config_dir)) # Not using async_add_executor_job because this is an internal method. - config = await hass.loop.run_in_executor( - None, - load_yaml_config_file, - hass.config.path(YAML_CONFIG_FILE), - secrets, - ) + try: + config = await hass.loop.run_in_executor( + None, + load_yaml_config_file, + hass.config.path(YAML_CONFIG_FILE), + secrets, + ) + except HomeAssistantError as ex: + if not (base_ex := ex.__cause__) or not isinstance(base_ex, MarkedYAMLError): + raise + + # Rewrite path to offending YAML file to be relative the hass config dir + if base_ex.context_mark and base_ex.context_mark.name: + base_ex.context_mark.name = _relpath(hass, base_ex.context_mark.name) + if base_ex.problem_mark and base_ex.problem_mark.name: + base_ex.problem_mark.name = _relpath(hass, base_ex.problem_mark.name) + raise + core_config = config.get(CONF_CORE, {}) await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) return config diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 785989ad839..e8a0f3c1189 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -349,7 +349,7 @@ # name: test_yaml_error[basic] ''' mapping values are not allowed here - in "/fixtures/core/config/yaml_errors/basic/configuration.yaml", line 4, column 14 + in "configuration.yaml", line 4, column 14 ''' # --- # name: test_yaml_error[basic].1 @@ -363,7 +363,7 @@ # name: test_yaml_error[basic_include] ''' mapping values are not allowed here - in "/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml", line 3, column 12 + in "integrations/iot_domain.yaml", line 3, column 12 ''' # --- # name: test_yaml_error[basic_include].1 @@ -377,7 +377,7 @@ # name: test_yaml_error[include_dir_list] ''' mapping values are not allowed here - in "/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml", line 3, column 10 + in "iot_domain/iot_domain_1.yaml", line 3, column 10 ''' # --- # name: test_yaml_error[include_dir_list].1 @@ -391,7 +391,7 @@ # name: test_yaml_error[include_dir_merge_list] ''' mapping values are not allowed here - in "/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml", line 3, column 12 + in "iot_domain/iot_domain_1.yaml", line 3, column 12 ''' # --- # name: test_yaml_error[include_dir_merge_list].1 @@ -405,7 +405,7 @@ # name: test_yaml_error[packages_include_dir_named] ''' mapping values are not allowed here - in "/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml", line 4, column 9 + in "integrations/adr_0007_1.yaml", line 4, column 9 ''' # --- # name: test_yaml_error[packages_include_dir_named].1 From 80813e992d303b5deb9883d9f5f23cba516f79b0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Nov 2023 23:01:00 +0100 Subject: [PATCH 565/982] Improve formatting of component errors (#104081) * Improve formatting of component errors * Update tests --- homeassistant/config.py | 14 +- tests/components/knx/test_button.py | 2 +- tests/components/rest/test_switch.py | 4 +- tests/components/template/test_cover.py | 2 +- tests/helpers/test_check_config.py | 12 +- tests/snapshots/test_config.ambr | 168 ++++++++++++------------ 6 files changed, 101 insertions(+), 101 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 27b7ad09bd0..6a840b01714 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -626,9 +626,9 @@ def stringify_invalid( - Give a more user friendly output for unknown options - Give a more user friendly output for missing options """ - message_prefix = f"Invalid config for [{domain}]" + message_prefix = f"Invalid config for '{domain}'" if domain != CONF_CORE and link: - message_suffix = f". Please check the docs at {link}" + message_suffix = f", please check the docs at {link}" else: message_suffix = "" if annotation := find_annotation(config, ex.path): @@ -636,13 +636,13 @@ def stringify_invalid( path = "->".join(str(m) for m in ex.path) if ex.error_message == "extra keys not allowed": return ( - f"{message_prefix}: '{ex.path[-1]}' is an invalid option for [{domain}], " + f"{message_prefix}: '{ex.path[-1]}' is an invalid option for '{domain}', " f"check: {path}{message_suffix}" ) if ex.error_message == "required key not provided": return ( f"{message_prefix}: required key '{ex.path[-1]}' not provided" - f"{message_suffix}." + f"{message_suffix}" ) # This function is an alternative to the stringification done by # vol.Invalid.__str__, so we need to call Exception.__str__ here @@ -657,7 +657,7 @@ def stringify_invalid( ) return ( f"{message_prefix}: {output} '{path}', got {offending_item_summary}" - f"{message_suffix}." + f"{message_suffix}" ) @@ -697,14 +697,14 @@ def format_homeassistant_error( link: str | None = None, ) -> str: """Format HomeAssistantError thrown by a custom config validator.""" - message_prefix = f"Invalid config for [{domain}]" + message_prefix = f"Invalid config for '{domain}'" # HomeAssistantError raised by custom config validator has no path to the # offending configuration key, use the domain key as path instead. if annotation := find_annotation(config, [domain]): message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" message = f"{message_prefix}: {str(ex) or repr(ex)}" if domain != CONF_CORE and link: - message += f" Please check the docs at {link}." + message += f", please check the docs at {link}" return message diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 3e8519feb98..08afabbbdf8 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -130,7 +130,7 @@ async def test_button_invalid( assert len(caplog.messages) == 2 record = caplog.records[0] assert record.levelname == "ERROR" - assert f"Invalid config for [knx]: {error_msg}" in record.message + assert f"Invalid config for 'knx': {error_msg}" in record.message record = caplog.records[1] assert record.levelname == "ERROR" assert "Setup failed for knx: Invalid config." in record.message diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 79dba7844fd..df90af44e73 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -62,7 +62,7 @@ async def test_setup_missing_config( await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) assert ( - "Invalid config for [switch.rest]: required key 'resource' not provided" + "Invalid config for 'switch.rest': required key 'resource' not provided" in caplog.text ) @@ -75,7 +75,7 @@ async def test_setup_missing_schema( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) - assert "Invalid config for [switch.rest]: invalid url" in caplog.text + assert "Invalid config for 'switch.rest': invalid url" in caplog.text @respx.mock diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index fefad59aa08..35f03ee9508 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -424,7 +424,7 @@ async def test_template_open_or_position( ) -> None: """Test that at least one of open_cover or set_position is used.""" assert hass.states.async_all("cover") == [] - assert "Invalid config for [cover.template]" in caplog_setup_text + assert "Invalid config for 'cover.template'" in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 1dd07bc66ac..b83f423e312 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -82,8 +82,8 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: error = CheckConfigError( ( - f"Invalid config for [homeassistant] at {YAML_CONFIG_FILE}, line 2:" - " not a valid value for dictionary value 'unit_system', got 'bad'." + f"Invalid config for 'homeassistant' at {YAML_CONFIG_FILE}, line 2:" + " not a valid value for dictionary value 'unit_system', got 'bad'" ), "homeassistant", {"unit_system": "bad"}, @@ -190,9 +190,9 @@ async def test_component_import_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("component", "errors", "warnings", "message"), [ - ("frontend", 1, 0, "'blah' is an invalid option for [frontend]"), - ("http", 1, 0, "'blah' is an invalid option for [http]"), - ("logger", 0, 1, "'blah' is an invalid option for [logger]"), + ("frontend", 1, 0, "'blah' is an invalid option for 'frontend'"), + ("http", 1, 0, "'blah' is an invalid option for 'http'"), + ("logger", 0, 1, "'blah' is an invalid option for 'logger'"), ], ) async def test_component_schema_error( @@ -453,7 +453,7 @@ action: HomeAssistantError("Broken"), 0, 1, - "Invalid config for [bla] at configuration.yaml, line 11: Broken", + "Invalid config for 'bla' at configuration.yaml, line 11: Broken", ), ], ) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index e8a0f3c1189..599964d0f4a 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -3,51 +3,51 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at configuration.yaml, line 6: required key 'platform' not provided.", + 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 18: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_2] at configuration.yaml, line 27: required key 'host' not provided.", + 'message': "Invalid config for 'adr_0007_2' at configuration.yaml, line 27: required key 'host' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_3] at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + 'message': "Invalid config for 'adr_0007_3' at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_4] at configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + 'message': "Invalid config for 'adr_0007_4' at configuration.yaml, line 37: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [adr_0007_5] at configuration.yaml, line 43: required key 'host' not provided. - Invalid config for [adr_0007_5] at configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + Invalid config for 'adr_0007_5' at configuration.yaml, line 43: required key 'host' not provided + Invalid config for 'adr_0007_5' at configuration.yaml, line 44: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option + Invalid config for 'adr_0007_5' at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo' ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 52: required key 'host' not provided.", + 'message': "Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 52: required key 'host' not provided", }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 55: broken', + 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 55: broken", }), dict({ 'has_exc_info': True, @@ -59,51 +59,51 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at integrations/iot_domain.yaml, line 5: required key 'platform' not provided.", + 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 5: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 17: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123. + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_2] at configuration.yaml, line 3: required key 'host' not provided.", + 'message': "Invalid config for 'adr_0007_2' at configuration.yaml, line 3: required key 'host' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_3] at integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + 'message': "Invalid config for 'adr_0007_3' at integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_4] at integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + 'message': "Invalid config for 'adr_0007_4' at integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [adr_0007_5] at configuration.yaml, line 6: required key 'host' not provided. - Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + Invalid config for 'adr_0007_5' at configuration.yaml, line 6: required key 'host' not provided + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo' ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 8: required key 'host' not provided.", + 'message': "Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 8: required key 'host' not provided", }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 9: broken', + 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 9: broken", }), dict({ 'has_exc_info': True, @@ -115,27 +115,27 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at iot_domain/iot_domain_2.yaml, line 2: required key 'platform' not provided.", + 'message': "Invalid config for 'iot_domain' at iot_domain/iot_domain_2.yaml, line 2: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123. + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 ''', }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 1: broken', + 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 1: broken", }), dict({ 'has_exc_info': True, @@ -147,27 +147,27 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at iot_domain/iot_domain_1.yaml, line 5: required key 'platform' not provided.", + 'message': "Invalid config for 'iot_domain' at iot_domain/iot_domain_1.yaml, line 5: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123. + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 ''', }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 1: broken', + 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 1: broken", }), dict({ 'has_exc_info': True, @@ -179,51 +179,51 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at configuration.yaml, line 11: required key 'platform' not provided.", + 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 11: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 21: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 29: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 30: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123. + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_2] at configuration.yaml, line 38: required key 'host' not provided.", + 'message': "Invalid config for 'adr_0007_2' at configuration.yaml, line 38: required key 'host' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_3] at configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + 'message': "Invalid config for 'adr_0007_3' at configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_4] at configuration.yaml, line 48: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + 'message': "Invalid config for 'adr_0007_4' at configuration.yaml, line 48: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [adr_0007_5] at configuration.yaml, line 54: required key 'host' not provided. - Invalid config for [adr_0007_5] at configuration.yaml, line 55: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + Invalid config for 'adr_0007_5' at configuration.yaml, line 54: required key 'host' not provided + Invalid config for 'adr_0007_5' at configuration.yaml, line 55: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option + Invalid config for 'adr_0007_5' at configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo' ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 64: required key 'host' not provided.", + 'message': "Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 64: required key 'host' not provided", }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 67: broken', + 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 67: broken", }), dict({ 'has_exc_info': True, @@ -235,51 +235,51 @@ list([ dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain] at integrations/iot_domain.yaml, line 6: required key 'platform' not provided.", + 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 6: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123.", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option", + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 18: required key 'option1' not provided. - Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option - Invalid config for [iot_domain.non_adr_0007] at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123. + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_2] at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided.", + 'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_3] at integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'.", + 'message': "Invalid config for 'adr_0007_3' at integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [adr_0007_4] at integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option", + 'message': "Invalid config for 'adr_0007_4' at integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 5: required key 'host' not provided. - Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option - Invalid config for [adr_0007_5] at integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo'. + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 5: required key 'host' not provided + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo' ''', }), dict({ 'has_exc_info': False, - 'message': "Invalid config for [custom_validator_ok_2] at integrations/custom_validator_ok_2.yaml, line 2: required key 'host' not provided.", + 'message': "Invalid config for 'custom_validator_ok_2' at integrations/custom_validator_ok_2.yaml, line 2: required key 'host' not provided", }), dict({ 'has_exc_info': True, - 'message': 'Invalid config for [custom_validator_bad_1] at integrations/custom_validator_bad_1.yaml, line 2: broken', + 'message': "Invalid config for 'custom_validator_bad_1' at integrations/custom_validator_bad_1.yaml, line 2: broken", }), dict({ 'has_exc_info': True, @@ -289,24 +289,24 @@ # --- # name: test_component_config_validation_error_with_docs[basic] list([ - "Invalid config for [iot_domain] at configuration.yaml, line 6: required key 'platform' not provided. Please check the docs at https://www.home-assistant.io/integrations/iot_domain.", - "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007.", - "Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 12: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain", + "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", ''' - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 18: required key 'option1' not provided. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 19: 'no_such_option' is an invalid option for [iot_domain.non_adr_0007], check: no_such_option. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for [iot_domain.non_adr_0007] at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123. Please check the docs at https://www.home-assistant.io/integrations/non_adr_0007. + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 ''', - "Invalid config for [adr_0007_2] at configuration.yaml, line 27: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_2.", - "Invalid config for [adr_0007_3] at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_3.", - "Invalid config for [adr_0007_4] at configuration.yaml, line 37: 'no_such_option' is an invalid option for [adr_0007_4], check: adr_0007_4->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", + "Invalid config for 'adr_0007_2' at configuration.yaml, line 27: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/adr_0007_2", + "Invalid config for 'adr_0007_3' at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo', please check the docs at https://www.home-assistant.io/integrations/adr_0007_3", + "Invalid config for 'adr_0007_4' at configuration.yaml, line 37: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option, please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", ''' - Invalid config for [adr_0007_5] at configuration.yaml, line 43: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. - Invalid config for [adr_0007_5] at configuration.yaml, line 44: 'no_such_option' is an invalid option for [adr_0007_5], check: adr_0007_5->no_such_option. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 - Invalid config for [adr_0007_5] at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo'. Please check the docs at https://www.home-assistant.io/integrations/adr_0007_5. + Invalid config for 'adr_0007_5' at configuration.yaml, line 43: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 + Invalid config for 'adr_0007_5' at configuration.yaml, line 44: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option, please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 + Invalid config for 'adr_0007_5' at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo', please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 ''', - "Invalid config for [custom_validator_ok_2] at configuration.yaml, line 52: required key 'host' not provided. Please check the docs at https://www.home-assistant.io/integrations/custom_validator_ok_2.", - 'Invalid config for [custom_validator_bad_1] at configuration.yaml, line 55: broken Please check the docs at https://www.home-assistant.io/integrations/custom_validator_bad_1.', + "Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 52: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/custom_validator_ok_2", + "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 55: broken, please check the docs at https://www.home-assistant.io/integrations/custom_validator_bad_1", 'Unknown error calling custom_validator_bad_2 config validator', ]) # --- From bf87773d8701244e620496c1945a48becc149e1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Nov 2023 16:20:54 -0600 Subject: [PATCH 566/982] Fix zeroconf mocking (#104144) --- tests/conftest.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 09ad70bfcf1..4050c1cdb6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1145,13 +1145,19 @@ def mock_zeroconf() -> Generator[None, None, None]: @pytest.fixture def mock_async_zeroconf(mock_zeroconf: None) -> Generator[None, None, None]: """Mock AsyncZeroconf.""" - from zeroconf import DNSCache # pylint: disable=import-outside-toplevel + from zeroconf import DNSCache, Zeroconf # pylint: disable=import-outside-toplevel + from zeroconf.asyncio import ( # pylint: disable=import-outside-toplevel + AsyncZeroconf, + ) - with patch("homeassistant.components.zeroconf.HaAsyncZeroconf") as mock_aiozc: + with patch( + "homeassistant.components.zeroconf.HaAsyncZeroconf", spec=AsyncZeroconf + ) as mock_aiozc: zc = mock_aiozc.return_value zc.async_unregister_service = AsyncMock() zc.async_register_service = AsyncMock() zc.async_update_service = AsyncMock() + zc.zeroconf = Mock(spec=Zeroconf) zc.zeroconf.async_wait_for_start = AsyncMock() # DNSCache has strong Cython type checks, and MagicMock does not work # so we must mock the class directly From 0d2bcc0192d4524312ccf00a78cf1919e8b061b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Nov 2023 17:16:44 -0600 Subject: [PATCH 567/982] Bump aioesphomeapi to 18.5.3 (#104141) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 90de3af9f36..bc2b87f6397 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.5.2", + "aioesphomeapi==18.5.3", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index b0aff4e51f7..eb046f86994 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.2 +aioesphomeapi==18.5.3 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d12c8688c7..63a5428b307 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.2 +aioesphomeapi==18.5.3 # homeassistant.components.flo aioflo==2021.11.0 From 878ccbaaef4dc5e525f8de8442899527af02b69c Mon Sep 17 00:00:00 2001 From: Thomas Schamm Date: Sat, 18 Nov 2023 13:39:17 +0100 Subject: [PATCH 568/982] Bump boschshcpy to 0.2.75 (#104159) Bumped to boschshcpy==0.2.75 --- homeassistant/components/bosch_shc/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 9fd1055dd60..e29865153b3 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_shc", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.2.57"], + "requirements": ["boschshcpy==0.2.75"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index eb046f86994..a8ccb26797f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -573,7 +573,7 @@ bluetooth-data-tools==1.14.0 bond-async==0.2.1 # homeassistant.components.bosch_shc -boschshcpy==0.2.57 +boschshcpy==0.2.75 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63a5428b307..89a5db104ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ bluetooth-data-tools==1.14.0 bond-async==0.2.1 # homeassistant.components.bosch_shc -boschshcpy==0.2.57 +boschshcpy==0.2.75 # homeassistant.components.broadlink broadlink==0.18.3 From 6773c29ccca82713849c5b219d0e7a3d4932a386 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 18 Nov 2023 15:53:49 +0100 Subject: [PATCH 569/982] Update aiohttp to 3.9.0 (Python 3.12) (#104152) --- 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 99b6a6a8392..ed695563e46 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.5.1 aiohttp-fast-url-dispatcher==0.1.0 aiohttp-zlib-ng==0.1.1 aiohttp==3.8.5;python_version<'3.12' -aiohttp==3.9.0rc0;python_version>='3.12' +aiohttp==3.9.0;python_version>='3.12' aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.2 diff --git a/pyproject.toml b/pyproject.toml index b7adf946223..13bcc9987ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.9.0rc0;python_version>='3.12'", + "aiohttp==3.9.0;python_version>='3.12'", "aiohttp==3.8.5;python_version<'3.12'", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.1.0", diff --git a/requirements.txt b/requirements.txt index 5b33374b7cc..6c10af6f2ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.9.0rc0;python_version>='3.12' +aiohttp==3.9.0;python_version>='3.12' aiohttp==3.8.5;python_version<'3.12' aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.1.0 From dfff22b5ceb0130892930e0399dc7228cb7ae822 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 18 Nov 2023 17:07:58 +0100 Subject: [PATCH 570/982] Add update coordinator to ping (#104148) * Add update coordinator to ping * Remove config_entry from coordinator * Remove PARALLEL_UPDATES and set to hass.data --- .coveragerc | 1 + homeassistant/components/ping/__init__.py | 32 ++++++++- .../components/ping/binary_sensor.py | 71 ++++--------------- homeassistant/components/ping/coordinator.py | 53 ++++++++++++++ .../components/ping/device_tracker.py | 38 +++------- 5 files changed, 105 insertions(+), 90 deletions(-) create mode 100644 homeassistant/components/ping/coordinator.py diff --git a/.coveragerc b/.coveragerc index 13de6cb29c8..40fcd50f57f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -948,6 +948,7 @@ omit = homeassistant/components/pilight/switch.py homeassistant/components/ping/__init__.py homeassistant/components/ping/binary_sensor.py + homeassistant/components/ping/coordinator.py homeassistant/components/ping/device_tracker.py homeassistant/components/ping/helpers.py homeassistant/components/pioneer/media_player.py diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 3280173813d..81df1401f91 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -7,12 +7,14 @@ import logging from icmplib import SocketPermissionError, async_ping from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import CONF_PING_COUNT, DOMAIN +from .coordinator import PingUpdateCoordinator +from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) @@ -25,6 +27,7 @@ class PingDomainData: """Dataclass to store privileged status.""" privileged: bool | None + coordinators: dict[str, PingUpdateCoordinator] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -32,6 +35,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = PingDomainData( privileged=await _can_use_icmp_lib_with_privilege(), + coordinators={}, ) return True @@ -40,6 +44,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ping (ICMP) from a config entry.""" + data: PingDomainData = hass.data[DOMAIN] + + host: str = entry.options[CONF_HOST] + count: int = int(entry.options[CONF_PING_COUNT]) + ping_cls: type[PingDataICMPLib | PingDataSubProcess] + if data.privileged is None: + ping_cls = PingDataSubProcess + else: + ping_cls = PingDataICMPLib + + coordinator = PingUpdateCoordinator( + hass=hass, ping=ping_cls(hass, host, count, data.privileged) + ) + await coordinator.async_config_entry_first_refresh() + + data.coordinators[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) @@ -53,7 +74,12 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + # drop coordinator for config entry + hass.data[DOMAIN].coordinators.pop(entry.entry_id) + + return unload_ok async def _can_use_icmp_lib_with_privilege() -> None | bool: diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index f81a6b7d22d..97636111586 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -1,7 +1,6 @@ """Tracks the latency of a host by sending ICMP echo requests (ping).""" from __future__ import annotations -from datetime import timedelta import logging from typing import Any @@ -13,17 +12,17 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, STATE_ON +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN -from .helpers import PingDataICMPLib, PingDataSubProcess +from .coordinator import PingUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,10 +31,6 @@ ATTR_ROUND_TRIP_TIME_MAX = "round_trip_time_max" ATTR_ROUND_TRIP_TIME_MDEV = "round_trip_time_mdev" ATTR_ROUND_TRIP_TIME_MIN = "round_trip_time_min" -SCAN_INTERVAL = timedelta(minutes=5) - -PARALLEL_UPDATES = 50 - PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -86,31 +81,21 @@ async def async_setup_entry( data: PingDomainData = hass.data[DOMAIN] - host: str = entry.options[CONF_HOST] - count: int = int(entry.options[CONF_PING_COUNT]) - ping_cls: type[PingDataSubProcess | PingDataICMPLib] - if data.privileged is None: - ping_cls = PingDataSubProcess - else: - ping_cls = PingDataICMPLib - - async_add_entities( - [PingBinarySensor(entry, ping_cls(hass, host, count, data.privileged))] - ) + async_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) -class PingBinarySensor(RestoreEntity, BinarySensorEntity): +class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEntity): """Representation of a Ping Binary sensor.""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_available = False def __init__( - self, - config_entry: ConfigEntry, - ping_cls: PingDataSubProcess | PingDataICMPLib, + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping Binary sensor.""" + super().__init__(coordinator) + self._attr_name = config_entry.title self._attr_unique_id = config_entry.entry_id @@ -120,47 +105,19 @@ class PingBinarySensor(RestoreEntity, BinarySensorEntity): config_entry.data[CONF_IMPORTED_BY] == "binary_sensor" ) - self._ping = ping_cls - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._ping.is_alive + return self.coordinator.data.is_alive @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the ICMP checo request.""" - if self._ping.data is None: + if self.coordinator.data.data is None: return None return { - ATTR_ROUND_TRIP_TIME_AVG: self._ping.data["avg"], - ATTR_ROUND_TRIP_TIME_MAX: self._ping.data["max"], - ATTR_ROUND_TRIP_TIME_MDEV: self._ping.data["mdev"], - ATTR_ROUND_TRIP_TIME_MIN: self._ping.data["min"], - } - - async def async_update(self) -> None: - """Get the latest data.""" - await self._ping.async_update() - self._attr_available = True - - async def async_added_to_hass(self) -> None: - """Restore previous state on restart to avoid blocking startup.""" - await super().async_added_to_hass() - - last_state = await self.async_get_last_state() - if last_state is not None: - self._attr_available = True - - if last_state is None or last_state.state != STATE_ON: - self._ping.data = None - return - - attributes = last_state.attributes - self._ping.is_alive = True - self._ping.data = { - "min": attributes[ATTR_ROUND_TRIP_TIME_MIN], - "max": attributes[ATTR_ROUND_TRIP_TIME_MAX], - "avg": attributes[ATTR_ROUND_TRIP_TIME_AVG], - "mdev": attributes[ATTR_ROUND_TRIP_TIME_MDEV], + ATTR_ROUND_TRIP_TIME_AVG: self.coordinator.data.data["avg"], + ATTR_ROUND_TRIP_TIME_MAX: self.coordinator.data.data["max"], + ATTR_ROUND_TRIP_TIME_MDEV: self.coordinator.data.data["mdev"], + ATTR_ROUND_TRIP_TIME_MIN: self.coordinator.data.data["min"], } diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py new file mode 100644 index 00000000000..dadd105b606 --- /dev/null +++ b/homeassistant/components/ping/coordinator.py @@ -0,0 +1,53 @@ +"""DataUpdateCoordinator for the ping integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .helpers import PingDataICMPLib, PingDataSubProcess + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(slots=True, frozen=True) +class PingResult: + """Dataclass returned by the coordinator.""" + + ip_address: str + is_alive: bool + data: dict[str, Any] | None + + +class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): + """The Ping update coordinator.""" + + ping: PingDataSubProcess | PingDataICMPLib + + def __init__( + self, + hass: HomeAssistant, + ping: PingDataSubProcess | PingDataICMPLib, + ) -> None: + """Initialize the Ping coordinator.""" + self.ping = ping + + super().__init__( + hass, + _LOGGER, + name=f"Ping {ping.ip_address}", + update_interval=timedelta(minutes=5), + ) + + async def _async_update_data(self) -> PingResult: + """Trigger ping check.""" + await self.ping.async_update() + return PingResult( + ip_address=self.ping.ip_address, + is_alive=self.ping.is_alive, + data=self.ping.data, + ) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index af07325db00..ceff1b2e124 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -1,7 +1,6 @@ """Tracks devices by sending a ICMP echo request (ping).""" from __future__ import annotations -from datetime import timedelta import logging import voluptuous as vol @@ -19,16 +18,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN -from .helpers import PingDataICMPLib, PingDataSubProcess +from .coordinator import PingUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(minutes=5) - PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): {cv.slug: cv.string}, @@ -84,40 +81,25 @@ async def async_setup_entry( data: PingDomainData = hass.data[DOMAIN] - host: str = entry.options[CONF_HOST] - count: int = int(entry.options[CONF_PING_COUNT]) - ping_cls: type[PingDataSubProcess | PingDataICMPLib] - if data.privileged is None: - ping_cls = PingDataSubProcess - else: - ping_cls = PingDataICMPLib - - async_add_entities( - [PingDeviceTracker(entry, ping_cls(hass, host, count, data.privileged))] - ) + async_add_entities([PingDeviceTracker(entry, data.coordinators[entry.entry_id])]) -class PingDeviceTracker(ScannerEntity): +class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" - ping: PingDataSubProcess | PingDataICMPLib - def __init__( - self, - config_entry: ConfigEntry, - ping_cls: PingDataSubProcess | PingDataICMPLib, + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping device tracker.""" - super().__init__() + super().__init__(coordinator) self._attr_name = config_entry.title - self.ping = ping_cls self.config_entry = config_entry @property def ip_address(self) -> str: """Return the primary ip address of the device.""" - return self.ping.ip_address + return self.coordinator.data.ip_address @property def unique_id(self) -> str: @@ -132,7 +114,7 @@ class PingDeviceTracker(ScannerEntity): @property def is_connected(self) -> bool: """Return true if ping returns is_alive.""" - return self.ping.is_alive + return self.coordinator.data.is_alive @property def entity_registry_enabled_default(self) -> bool: @@ -140,7 +122,3 @@ class PingDeviceTracker(ScannerEntity): if CONF_IMPORTED_BY in self.config_entry.data: return bool(self.config_entry.data[CONF_IMPORTED_BY] == "device_tracker") return False - - async def async_update(self) -> None: - """Update the sensor.""" - await self.ping.async_update() From bee457ed6f6dd623fd785169a0815f04deb3f192 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 18 Nov 2023 15:22:30 -0500 Subject: [PATCH 571/982] Add Image to Roborock to display maps (#102941) * add image to roborock * add vacuum position * addressing MR comments * remove room names as it isn't supported in base package * 100% coverage * remove unneeded map changes * fix image logic * optimize create_coordinator_maps * only update time if map is valid * Update test_image.py * fix linting from merge conflict * fix mypy complaints * re-add vacuum to const * fix hanging test * Make map sleep a const * adjust commenting to be less than 88 characters. * bump map parser --- homeassistant/components/roborock/const.py | 13 ++ .../components/roborock/coordinator.py | 13 ++ homeassistant/components/roborock/device.py | 6 + homeassistant/components/roborock/image.py | 151 ++++++++++++++++++ .../components/roborock/manifest.json | 5 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/roborock/conftest.py | 19 ++- tests/components/roborock/mock_data.py | 34 ++++ tests/components/roborock/test_image.py | 75 +++++++++ 10 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/roborock/image.py create mode 100644 tests/components/roborock/test_image.py diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index d135f323e90..d7a3a9229f5 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -1,4 +1,6 @@ """Constants for Roborock.""" +from vacuum_map_parser_base.config.drawable import Drawable + from homeassistant.const import Platform DOMAIN = "roborock" @@ -9,6 +11,7 @@ CONF_USER_DATA = "user_data" PLATFORMS = [ Platform.BUTTON, Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -16,3 +19,13 @@ PLATFORMS = [ Platform.TIME, Platform.VACUUM, ] + +IMAGE_DRAWABLES: list[Drawable] = [ + Drawable.PATH, + Drawable.CHARGER, + Drawable.VACUUM_POSITION, +] + +IMAGE_CACHE_INTERVAL = 90 + +MAP_SLEEP = 3 diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 30bfc71ea48..cd08cf871d4 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -55,6 +55,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): model=self.roborock_device_info.product.model, sw_version=self.roborock_device_info.device.fv, ) + self.current_map: int | None = None if mac := self.roborock_device_info.network_info.mac: self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} @@ -91,6 +92,18 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Update data via library.""" try: await self._update_device_prop() + self._set_current_map() except RoborockException as ex: raise UpdateFailed(ex) from ex return self.roborock_device_info.props + + def _set_current_map(self) -> None: + if ( + self.roborock_device_info.props.status is not None + and self.roborock_device_info.props.status.map_status is not None + ): + # The map status represents the map flag as flag * 4 + 3 - + # so we have to invert that in order to get the map flag that we can use to set the current map. + self.current_map = ( + self.roborock_device_info.props.status.map_status - 3 + ) // 4 diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index c8f45b40d82..5fca40a9fd8 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -3,6 +3,7 @@ from typing import Any from roborock.api import AttributeCache, RoborockClient +from roborock.cloud_api import RoborockMqttClient from roborock.command_cache import CacheableAttribute from roborock.containers import Status from roborock.exceptions import RoborockException @@ -82,6 +83,11 @@ class RoborockCoordinatedEntity( data = self.coordinator.data return data.status + @property + def cloud_api(self) -> RoborockMqttClient: + """Return the cloud api.""" + return self.coordinator.cloud_api + async def send( self, command: RoborockCommand | str, diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py new file mode 100644 index 00000000000..6c4c7553c14 --- /dev/null +++ b/homeassistant/components/roborock/image.py @@ -0,0 +1,151 @@ +"""Support for Roborock image.""" +import asyncio +import io +from itertools import chain + +from roborock import RoborockCommand +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify +import homeassistant.util.dt as dt_util + +from .const import DOMAIN, IMAGE_CACHE_INTERVAL, IMAGE_DRAWABLES, MAP_SLEEP +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock image platform.""" + + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + entities = list( + chain.from_iterable( + await asyncio.gather( + *(create_coordinator_maps(coord) for coord in coordinators.values()) + ) + ) + ) + async_add_entities(entities) + + +class RoborockMap(RoborockCoordinatedEntity, ImageEntity): + """A class to let you visualize the map.""" + + _attr_has_entity_name = True + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + map_flag: int, + starting_map: bytes, + map_name: str, + ) -> None: + """Initialize a Roborock map.""" + RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) + ImageEntity.__init__(self, coordinator.hass) + self._attr_name = map_name + self.parser = RoborockMapDataParser( + ColorsPalette(), Sizes(), IMAGE_DRAWABLES, ImageConfig(), [] + ) + self._attr_image_last_updated = dt_util.utcnow() + self.map_flag = map_flag + self.cached_map = self._create_image(starting_map) + + def is_map_valid(self) -> bool: + """Update this map if it is the current active map, and the vacuum is cleaning.""" + return ( + self.map_flag == self.coordinator.current_map + and self.image_last_updated is not None + and self.coordinator.roborock_device_info.props.status is not None + and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) + ) + + def _handle_coordinator_update(self): + # Bump last updated every third time the coordinator runs, so that async_image + # will be called and we will evaluate on the new coordinator data if we should + # update the cache. + if ( + dt_util.utcnow() - self.image_last_updated + ).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid(): + self._attr_image_last_updated = dt_util.utcnow() + super()._handle_coordinator_update() + + async def async_image(self) -> bytes | None: + """Update the image if it is not cached.""" + if self.is_map_valid(): + map_data: bytes = await self.cloud_api.get_map_v1() + self.cached_map = self._create_image(map_data) + return self.cached_map + + def _create_image(self, map_bytes: bytes) -> bytes: + """Create an image using the map parser.""" + parsed_map = self.parser.parse(map_bytes) + if parsed_map.image is None: + raise HomeAssistantError("Something went wrong creating the map.") + img_byte_arr = io.BytesIO() + parsed_map.image.data.save(img_byte_arr, format="PNG") + return img_byte_arr.getvalue() + + +async def create_coordinator_maps( + coord: RoborockDataUpdateCoordinator, +) -> list[RoborockMap]: + """Get the starting map information for all maps for this device. The following steps must be done synchronously. + + Only one map can be loaded at a time per device. + """ + entities = [] + maps = await coord.cloud_api.get_multi_maps_list() + if maps is not None and maps.map_info is not None: + cur_map = coord.current_map + # This won't be None at this point as the coordinator will have run first. + assert cur_map is not None + # Sort the maps so that we start with the current map and we can skip the + # load_multi_map call. + maps_info = sorted( + maps.map_info, key=lambda data: data.mapFlag == cur_map, reverse=True + ) + for roborock_map in maps_info: + # Load the map - so we can access it with get_map_v1 + if roborock_map.mapFlag != cur_map: + # Only change the map and sleep if we have multiple maps. + await coord.api.send_command( + RoborockCommand.LOAD_MULTI_MAP, [roborock_map.mapFlag] + ) + # We cannot get the map until the roborock servers fully process the + # map change. + await asyncio.sleep(MAP_SLEEP) + # Get the map data + api_data: bytes = await coord.cloud_api.get_map_v1() + entities.append( + RoborockMap( + f"{slugify(coord.roborock_device_info.device.duid)}_map_{roborock_map.name}", + coord, + roborock_map.mapFlag, + api_data, + roborock_map.name, + ) + ) + if len(maps.map_info) != 1: + # Set the map back to the map the user previously had selected so that it + # does not change the end user's app. + # Only needs to happen when we changed maps above. + await coord.cloud_api.send_command( + RoborockCommand.LOAD_MULTI_MAP, [cur_map] + ) + return entities diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index ed043582a0e..3b741995cd4 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,8 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.36.1"] + "requirements": [ + "python-roborock==0.36.1", + "vacuum-map-parser-roborock==0.1.1" + ] } diff --git a/requirements_all.txt b/requirements_all.txt index a8ccb26797f..2efdb98189f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2671,6 +2671,9 @@ url-normalize==1.4.3 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.roborock +vacuum-map-parser-roborock==0.1.1 + # homeassistant.components.vallox vallox-websocket-api==4.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89a5db104ae..8b1319ad920 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1984,6 +1984,9 @@ url-normalize==1.4.3 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.roborock +vacuum-map-parser-roborock==0.1.1 + # homeassistant.components.vallox vallox-websocket-api==4.0.2 diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 3435bd58cb3..b0a01137ab9 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -12,7 +12,16 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .mock_data import BASE_URL, HOME_DATA, NETWORK_INFO, PROP, USER_DATA, USER_EMAIL +from .mock_data import ( + BASE_URL, + HOME_DATA, + MAP_DATA, + MULTI_MAP_LIST, + NETWORK_INFO, + PROP, + USER_DATA, + USER_EMAIL, +) from tests.common import MockConfigEntry @@ -33,6 +42,12 @@ def bypass_api_fixture() -> None: ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", return_value=PROP, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClient.get_multi_maps_list", + return_value=MULTI_MAP_LIST, + ), patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=MAP_DATA, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" ), patch( @@ -43,6 +58,8 @@ def bypass_api_fixture() -> None: "roborock.api.AttributeCache.async_value" ), patch( "roborock.api.AttributeCache.value" + ), patch( + "homeassistant.components.roborock.image.MAP_SLEEP", 0 ): yield diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 87ed02bc3ec..8935a77f142 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1,17 +1,22 @@ """Mock data for Roborock tests.""" from __future__ import annotations +from PIL import Image from roborock.containers import ( CleanRecord, CleanSummary, Consumable, DnDTimer, HomeData, + MultiMapsList, NetworkInfo, S7Status, UserData, ) from roborock.roborock_typing import DeviceProp +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.map_data import ImageData +from vacuum_map_parser_roborock.map_data_parser import MapData from homeassistant.components.roborock import CONF_BASE_URL, CONF_USER_DATA from homeassistant.const import CONF_USERNAME @@ -418,3 +423,32 @@ PROP = DeviceProp( NETWORK_INFO = NetworkInfo( ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 ) + +MULTI_MAP_LIST = MultiMapsList.from_dict( + { + "maxMultiMap": 4, + "maxBakMap": 1, + "multiMapCount": 2, + "mapInfo": [ + { + "mapFlag": 0, + "addTime": 1686235489, + "length": 8, + "name": "Upstairs", + "bakMaps": [{"addTime": 1673304288}], + }, + { + "mapFlag": 1, + "addTime": 1697579901, + "length": 10, + "name": "Downstairs", + "bakMaps": [{"addTime": 1695521431}], + }, + ], + } +) + +MAP_DATA = MapData(0, 0) +MAP_DATA.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p +) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py new file mode 100644 index 00000000000..80d4bd37337 --- /dev/null +++ b/tests/components/roborock/test_image.py @@ -0,0 +1,75 @@ +"""Test Roborock Image platform.""" +import copy +from datetime import timedelta +from http import HTTPStatus +from unittest.mock import patch + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.roborock.mock_data import MAP_DATA, PROP +from tests.typing import ClientSessionGenerator + + +async def test_floorplan_image( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test floor plan map image is correctly set up.""" + # Setup calls the image parsing the first time and caches it. + assert len(hass.states.async_all("image")) == 4 + + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + # call a second time -should return cached data + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body is not None + # Call a third time - this time forcing it to update + now = dt_util.utcnow() + timedelta(seconds=91) + async_fire_time_changed(hass, now) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + return_value=prop, + ), patch( + "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + ): + await hass.async_block_till_done() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body is not None + + +async def test_floorplan_image_failed_parse( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that we correctly handle getting None from the image parser.""" + client = await hass_client() + map_data = copy.deepcopy(MAP_DATA) + map_data.image = None + now = dt_util.utcnow() + timedelta(seconds=91) + async_fire_time_changed(hass, now) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + # Update image, but get none for parse image. + with patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + return_value=prop, + ), patch( + "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + ): + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert not resp.ok From ce38d8542fd5415b24a149ef29ee1e8e473b5b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 18 Nov 2023 22:17:05 +0000 Subject: [PATCH 572/982] Update Idasen Desk to fulfill Silver requirements (#102979) * Update Idasen Desk to fulfill Silver requirements * Add tests --- .../components/idasen_desk/__init__.py | 6 ++++ homeassistant/components/idasen_desk/cover.py | 25 +++++++++++--- .../components/idasen_desk/manifest.json | 1 + tests/components/idasen_desk/test_cover.py | 33 +++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 0a17ebec96c..564406d423e 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -44,6 +44,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): super().__init__(hass, logger, name=name) self._address = address self._expected_connected = False + self._connection_lost = False self.desk = Desk(self.async_set_updated_data) @@ -63,6 +64,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): """Disconnect from desk.""" _LOGGER.debug("Disconnecting from %s", self._address) self._expected_connected = False + self._connection_lost = False await self.desk.disconnect() @callback @@ -71,7 +73,11 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): if self._expected_connected: if not self.desk.is_connected: _LOGGER.debug("Desk disconnected. Reconnecting") + self._connection_lost = True self.hass.async_create_task(self.async_connect()) + elif self._connection_lost: + _LOGGER.info("Reconnected to desk") + self._connection_lost = False elif self.desk.is_connected: _LOGGER.warning("Desk is connected but should not be. Disconnecting") self.hass.async_create_task(self.desk.disconnect()) diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index 3148616d182..1daebe52420 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from bleak.exc import BleakError + from homeassistant.components.cover import ( ATTR_POSITION, CoverDeviceClass, @@ -12,6 +14,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -71,19 +74,33 @@ class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._desk.move_down() + try: + await self._desk.move_down() + except BleakError as err: + raise HomeAssistantError("Failed to move down: Bluetooth error") from err async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._desk.move_up() + try: + await self._desk.move_up() + except BleakError as err: + raise HomeAssistantError("Failed to move up: Bluetooth error") from err async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._desk.stop() + try: + await self._desk.stop() + except BleakError as err: + raise HomeAssistantError("Failed to stop moving: Bluetooth error") from err async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" - await self._desk.move_to(int(kwargs[ATTR_POSITION])) + try: + await self._desk.move_to(int(kwargs[ATTR_POSITION])) + except BleakError as err: + raise HomeAssistantError( + "Failed to move to specified position: Bluetooth error" + ) from err @callback def _handle_coordinator_update(self, *args: Any) -> None: diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index ed941f4f87d..9681b2136e1 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -11,5 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", + "quality_scale": "silver", "requirements": ["idasen-ha==2.3"] } diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py index a9c74be7081..4c8bf7806e0 100644 --- a/tests/components/idasen_desk/test_cover.py +++ b/tests/components/idasen_desk/test_cover.py @@ -2,6 +2,7 @@ from typing import Any from unittest.mock import MagicMock +from bleak.exc import BleakError import pytest from homeassistant.components.cover import ( @@ -19,6 +20,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import init_integration @@ -80,3 +82,34 @@ async def test_cover_services( assert state assert state.state == expected_state assert state.attributes[ATTR_CURRENT_POSITION] == expected_position + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method_name"), + [ + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 100}, "move_to"), + (SERVICE_OPEN_COVER, {}, "move_up"), + (SERVICE_CLOSE_COVER, {}, "move_down"), + (SERVICE_STOP_COVER, {}, "stop"), + ], +) +async def test_cover_services_exception( + hass: HomeAssistant, + mock_desk_api: MagicMock, + service: str, + service_data: dict[str, Any], + mock_method_name: str, +) -> None: + """Test cover services exception handling.""" + entity_id = "cover.test" + await init_integration(hass) + fail_call = getattr(mock_desk_api, mock_method_name) + fail_call.side_effect = BleakError() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **service_data}, + blocking=True, + ) + await hass.async_block_till_done() From 09df04fafedc9279966efff4b1615ec30ddafd2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Nov 2023 16:39:47 -0600 Subject: [PATCH 573/982] Pickup tplink codeowner (#104189) I am going to attempt a fix for https://github.com/home-assistant/core/issues/103977 via https://github.com/python-kasa/python-kasa/pull/538 I am picking up codeowner on this for the forseeable future to watch for issues as well --- CODEOWNERS | 4 ++-- homeassistant/components/tplink/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index bc889a6f535..25f8702ab5a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1319,8 +1319,8 @@ build.json @home-assistant/supervisor /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek -/homeassistant/components/tplink/ @rytilahti @thegardenmonkey -/tests/components/tplink/ @rytilahti @thegardenmonkey +/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco +/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco /homeassistant/components/tplink_omada/ @MarkGodwin /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index e0ac41bdec6..162344f04ec 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -1,7 +1,7 @@ { "domain": "tplink", "name": "TP-Link Kasa Smart", - "codeowners": ["@rytilahti", "@thegardenmonkey"], + "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco"], "config_flow": true, "dependencies": ["network"], "dhcp": [ From c2e81bbafb514e96f420a67d43c5c9fb3380fbbe Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 18 Nov 2023 23:45:24 +0100 Subject: [PATCH 574/982] Add entity tests for ping (#104168) * Add entity tests for ping * Remove unused param * Use async_setup of config_entries --- .coveragerc | 3 - tests/components/ping/conftest.py | 40 ++++++ .../ping/fixtures/configuration.yaml | 5 - .../ping/snapshots/test_binary_sensor.ambr | 121 ++++++++++++++++++ tests/components/ping/test_binary_sensor.py | 89 +++++++++++++ tests/components/ping/test_device_tracker.py | 62 +++++++++ 6 files changed, 312 insertions(+), 8 deletions(-) delete mode 100644 tests/components/ping/fixtures/configuration.yaml create mode 100644 tests/components/ping/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/ping/test_binary_sensor.py create mode 100644 tests/components/ping/test_device_tracker.py diff --git a/.coveragerc b/.coveragerc index 40fcd50f57f..97ee86f1d44 100644 --- a/.coveragerc +++ b/.coveragerc @@ -947,9 +947,6 @@ omit = homeassistant/components/pilight/light.py homeassistant/components/pilight/switch.py homeassistant/components/ping/__init__.py - homeassistant/components/ping/binary_sensor.py - homeassistant/components/ping/coordinator.py - homeassistant/components/ping/device_tracker.py homeassistant/components/ping/helpers.py homeassistant/components/pioneer/media_player.py homeassistant/components/plaato/__init__.py diff --git a/tests/components/ping/conftest.py b/tests/components/ping/conftest.py index ded562b81d6..4ad06a09c1c 100644 --- a/tests/components/ping/conftest.py +++ b/tests/components/ping/conftest.py @@ -1,8 +1,16 @@ """Test configuration for ping.""" from unittest.mock import patch +from icmplib import Host import pytest +from homeassistant.components.ping import DOMAIN +from homeassistant.components.ping.const import CONF_PING_COUNT +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + @pytest.fixture def patch_setup(*args, **kwargs): @@ -12,3 +20,35 @@ def patch_setup(*args, **kwargs): return_value=True, ), patch("homeassistant.components.ping.async_setup", return_value=True): yield + + +@pytest.fixture(autouse=True) +async def patch_ping(): + """Patch icmplib async_ping.""" + mock = Host("10.10.10.10", 5, [10, 1, 2]) + + with patch( + "homeassistant.components.ping.helpers.async_ping", return_value=mock + ), patch("homeassistant.components.ping.async_ping", return_value=mock): + yield mock + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + title="10.10.10.10", + options={CONF_HOST: "10.10.10.10", CONF_PING_COUNT: 10.0}, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, patch_ping +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ping/fixtures/configuration.yaml b/tests/components/ping/fixtures/configuration.yaml deleted file mode 100644 index 201c020835e..00000000000 --- a/tests/components/ping/fixtures/configuration.yaml +++ /dev/null @@ -1,5 +0,0 @@ -binary_sensor: - - platform: ping - name: test2 - host: 127.0.0.1 - count: 1 diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2ce320d561b --- /dev/null +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: test_sensor + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.10_10_10_10', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '10.10.10.10', + 'platform': 'ping', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': '10.10.10.10', + 'round_trip_time_avg': 4.333, + 'round_trip_time_max': 10, + 'round_trip_time_mdev': '', + 'round_trip_time_min': 1, + }), + 'context': , + 'entity_id': 'binary_sensor.10_10_10_10', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': '10.10.10.10', + }), + 'context': , + 'entity_id': 'binary_sensor.10_10_10_10', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_and_update + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.10_10_10_10', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '10.10.10.10', + 'platform': 'ping', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_and_update.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': '10.10.10.10', + 'round_trip_time_avg': 4.333, + 'round_trip_time_max': 10, + 'round_trip_time_mdev': '', + 'round_trip_time_min': 1, + }), + 'context': , + 'entity_id': 'binary_sensor.10_10_10_10', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_and_update.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': '10.10.10.10', + }), + 'context': , + 'entity_id': 'binary_sensor.10_10_10_10', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py new file mode 100644 index 00000000000..b1066895e2b --- /dev/null +++ b/tests/components/ping/test_binary_sensor.py @@ -0,0 +1,89 @@ +"""Test the binary sensor platform of ping.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from icmplib import Host +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("setup_integration") +async def test_setup_and_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor setup and update.""" + + # check if binary sensor is there + entry = entity_registry.async_get("binary_sensor.10_10_10_10") + assert entry == snapshot(exclude=props("unique_id")) + + state = hass.states.get("binary_sensor.10_10_10_10") + assert state == snapshot + + # check if the sensor turns off. + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), + ): + freezer.tick(timedelta(minutes=6)) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.10_10_10_10") + assert state == snapshot + + +async def test_disabled_after_import( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +): + """Test if binary sensor is disabled after import.""" + config_entry.data = {CONF_IMPORTED_BY: "device_tracker"} + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # check if entity is disabled after import by device tracker + entry = entity_registry.async_get("binary_sensor.10_10_10_10") + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_import_issue_creation( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +): + """Test if import issue is raised.""" + + await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "ping", + "name": "test", + "host": "127.0.0.1", + "count": 1, + } + }, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py new file mode 100644 index 00000000000..b6cc6b42912 --- /dev/null +++ b/tests/components/ping/test_device_tracker.py @@ -0,0 +1,62 @@ +"""Test the binary sensor platform of ping.""" + +import pytest + +from homeassistant.components.ping.const import DOMAIN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("setup_integration") +async def test_setup_and_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test sensor setup and update.""" + + entry = entity_registry.async_get("device_tracker.10_10_10_10") + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # check device tracker state is not there + state = hass.states.get("device_tracker.10_10_10_10") + assert state is None + + # enable the entity + updated_entry = entity_registry.async_update_entity( + entity_id="device_tracker.10_10_10_10", disabled_by=None + ) + assert updated_entry != entry + assert updated_entry.disabled is False + + # reload config entry to enable entity + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # check device tracker is now "home" + state = hass.states.get("device_tracker.10_10_10_10") + assert state.state == "home" + + +async def test_import_issue_creation( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +): + """Test if import issue is raised.""" + + await async_setup_component( + hass, + "device_tracker", + {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue From 9c86adf644506bdc80344c394d27995d760b6c69 Mon Sep 17 00:00:00 2001 From: jflefebvre06 Date: Sun, 19 Nov 2023 00:02:00 +0100 Subject: [PATCH 575/982] Fix integration failed when freebox is configured in bridge mode (#103221) --- .coveragerc | 2 - homeassistant/components/freebox/router.py | 44 ++++++++++++++++- tests/components/freebox/conftest.py | 21 +++++++- tests/components/freebox/const.py | 4 +- .../fixtures/lan_get_hosts_list_bridge.json | 5 ++ .../components/freebox/test_device_tracker.py | 49 +++++++++++++++++++ tests/components/freebox/test_router.py | 22 +++++++++ 7 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 tests/components/freebox/fixtures/lan_get_hosts_list_bridge.json create mode 100644 tests/components/freebox/test_device_tracker.py create mode 100644 tests/components/freebox/test_router.py diff --git a/.coveragerc b/.coveragerc index 97ee86f1d44..a05cc48785e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -425,9 +425,7 @@ omit = homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py homeassistant/components/freebox/camera.py - homeassistant/components/freebox/device_tracker.py homeassistant/components/freebox/home_base.py - homeassistant/components/freebox/router.py homeassistant/components/freebox/switch.py homeassistant/components/fritz/common.py homeassistant/components/fritz/device_tracker.py diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 6a73624a776..765761c43f2 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -4,9 +4,11 @@ from __future__ import annotations from collections.abc import Mapping from contextlib import suppress from datetime import datetime +import json import logging import os from pathlib import Path +import re from typing import Any from freebox_api import Freepybox @@ -36,6 +38,20 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +def is_json(json_str): + """Validate if a String is a JSON value or not.""" + try: + json.loads(json_str) + return True + except (ValueError, TypeError) as err: + _LOGGER.error( + "Failed to parse JSON '%s', error '%s'", + json_str, + err, + ) + return False + + async def get_api(hass: HomeAssistant, host: str) -> Freepybox: """Get the Freebox API.""" freebox_path = Store(hass, STORAGE_VERSION, STORAGE_KEY).path @@ -69,6 +85,7 @@ class FreeboxRouter: self._sw_v: str = freebox_config["firmware_version"] self._attrs: dict[str, Any] = {} + self.supports_hosts = True self.devices: dict[str, dict[str, Any]] = {} self.disks: dict[int, dict[str, Any]] = {} self.supports_raid = True @@ -89,7 +106,32 @@ class FreeboxRouter: async def update_device_trackers(self) -> None: """Update Freebox devices.""" new_device = False - fbx_devices: list[dict[str, Any]] = await self._api.lan.get_hosts_list() + + fbx_devices: list[dict[str, Any]] = [] + + # Access to Host list not available in bridge mode, API return error_code 'nodev' + if self.supports_hosts: + try: + fbx_devices = await self._api.lan.get_hosts_list() + except HttpRequestError as err: + if ( + ( + matcher := re.search( + r"Request failed \(APIResponse: (.+)\)", str(err) + ) + ) + and is_json(json_str := matcher.group(1)) + and (json_resp := json.loads(json_str)).get("error_code") == "nodev" + ): + # No need to retry, Host list not available + self.supports_hosts = False + _LOGGER.debug( + "Host list is not available using bridge mode (%s)", + json_resp.get("msg"), + ) + + else: + raise err # Adds the Freebox itself fbx_devices.append( diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 8a6590d1105..39ed596e6db 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -1,6 +1,8 @@ """Test helpers for Freebox.""" +import json from unittest.mock import AsyncMock, PropertyMock, patch +from freebox_api.exceptions import HttpRequestError import pytest from homeassistant.core import HomeAssistant @@ -12,6 +14,7 @@ from .const import ( DATA_HOME_GET_NODES, DATA_HOME_PIR_GET_VALUES, DATA_LAN_GET_HOSTS_LIST, + DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, DATA_SYSTEM_GET_CONFIG, @@ -41,7 +44,9 @@ def enable_all_entities(): @pytest.fixture -def mock_device_registry_devices(hass: HomeAssistant, device_registry): +def mock_device_registry_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +): """Create device registry devices so the device tracker entities are enabled.""" config_entry = MockConfigEntry(domain="something_else") config_entry.add_to_hass(hass) @@ -87,3 +92,17 @@ def mock_router(mock_device_registry_devices): ) instance.close = AsyncMock() yield service_mock + + +@pytest.fixture(name="router_bridge_mode") +def mock_router_bridge_mode(mock_device_registry_devices, router): + """Mock a successful connection to Freebox Bridge mode.""" + + router().lan.get_hosts_list = AsyncMock( + side_effect=HttpRequestError( + "Request failed (APIResponse: %s)" + % json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE) + ) + ) + + return router diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index a7dd3132719..84667bf9d70 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -25,7 +25,9 @@ WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture("freebox/wifi_get_global_confi # device_tracker DATA_LAN_GET_HOSTS_LIST = load_json_array_fixture("freebox/lan_get_hosts_list.json") - +DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE = load_json_object_fixture( + "freebox/lan_get_hosts_list_bridge.json" +) # Home # ALL diff --git a/tests/components/freebox/fixtures/lan_get_hosts_list_bridge.json b/tests/components/freebox/fixtures/lan_get_hosts_list_bridge.json new file mode 100644 index 00000000000..4afda465712 --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_hosts_list_bridge.json @@ -0,0 +1,5 @@ +{ + "msg": "Erreur lors de la récupération de la liste des hôtes : Interface invalide", + "success": false, + "error_code": "nodev" +} diff --git a/tests/components/freebox/test_device_tracker.py b/tests/components/freebox/test_device_tracker.py new file mode 100644 index 00000000000..6d4ca5fb7ee --- /dev/null +++ b/tests/components/freebox/test_device_tracker.py @@ -0,0 +1,49 @@ +"""Tests for the Freebox device trackers.""" +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.core import HomeAssistant + +from .common import setup_platform + +from tests.common import async_fire_time_changed + + +async def test_router_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + router: Mock, +) -> None: + """Test get_hosts_list invoqued multiple times if freebox into router mode.""" + await setup_platform(hass, DEVICE_TRACKER_DOMAIN) + + assert router().lan.get_hosts_list.call_count == 1 + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert router().lan.get_hosts_list.call_count == 2 + + +async def test_bridge_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + router_bridge_mode: Mock, +) -> None: + """Test get_hosts_list invoqued once if freebox into bridge mode.""" + await setup_platform(hass, DEVICE_TRACKER_DOMAIN) + + assert router_bridge_mode().lan.get_hosts_list.call_count == 1 + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # If get_hosts_list failed, not called again + assert router_bridge_mode().lan.get_hosts_list.call_count == 1 diff --git a/tests/components/freebox/test_router.py b/tests/components/freebox/test_router.py new file mode 100644 index 00000000000..595aab24fc9 --- /dev/null +++ b/tests/components/freebox/test_router.py @@ -0,0 +1,22 @@ +"""Tests for the Freebox utility methods.""" +import json + +from homeassistant.components.freebox.router import is_json + +from .const import DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, WIFI_GET_GLOBAL_CONFIG + + +async def test_is_json() -> None: + """Test is_json method.""" + + # Valid JSON values + assert is_json("{}") + assert is_json('{ "simple":"json" }') + assert is_json(json.dumps(WIFI_GET_GLOBAL_CONFIG)) + assert is_json(json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)) + + # Not valid JSON values + assert not is_json(None) + assert not is_json("") + assert not is_json("XXX") + assert not is_json("{XXX}") From 3622944dda22474556894e81fee4274bb3ecc79e Mon Sep 17 00:00:00 2001 From: Maxwell Burdick <16581552+coffeehorn@users.noreply.github.com> Date: Sat, 18 Nov 2023 23:14:06 -0600 Subject: [PATCH 576/982] Bump mopeka-iot-ble to 0.5.0 (#104186) --- homeassistant/components/mopeka/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index d6b5618bf97..766af715485 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/mopeka", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mopeka-iot-ble==0.4.1"] + "requirements": ["mopeka-iot-ble==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2efdb98189f..c67b3cc616c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1259,7 +1259,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.0 # homeassistant.components.mopeka -mopeka-iot-ble==0.4.1 +mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds motionblinds==0.6.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b1319ad920..fc63f193584 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -980,7 +980,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.0 # homeassistant.components.mopeka -mopeka-iot-ble==0.4.1 +mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds motionblinds==0.6.18 From 0eb8daee23fc31325288e012b250509b45219d56 Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 19 Nov 2023 04:41:17 -0500 Subject: [PATCH 577/982] Refactor async_update in Honeywell (#103069) * Refactor async_update * remove ignore-import * Restore somecomforterror rather than autherror * Update climate.py Limit exceptions in async_update() * Update climate.py Ruff it * Update climate.py Ruff * Refactor to login routine * Add back avialable change * Address extra logic in try * Address expected returns with logic move --- homeassistant/components/honeywell/climate.py | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index e9af4b2fd95..2c1c70d01eb 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -6,7 +6,12 @@ import datetime from typing import Any from aiohttp import ClientConnectionError -from aiosomecomfort import SomeComfortError, UnauthorizedError, UnexpectedResponse +from aiosomecomfort import ( + AuthError, + SomeComfortError, + UnauthorizedError, + UnexpectedResponse, +) from aiosomecomfort.device import Device as SomeComfortDevice from homeassistant.components.climate import ( @@ -492,31 +497,38 @@ class HoneywellUSThermostat(ClimateEntity): async def async_update(self) -> None: """Get the latest state from the service.""" - try: - await self._device.refresh() - self._attr_available = True - self._retry = 0 - except UnauthorizedError: + async def _login() -> None: try: await self._data.client.login() await self._device.refresh() - self._attr_available = True - self._retry = 0 except ( - SomeComfortError, + AuthError, ClientConnectionError, asyncio.TimeoutError, ): self._retry += 1 - if self._retry > RETRY: - self._attr_available = False + self._attr_available = self._retry <= RETRY + return + + self._attr_available = True + self._retry = 0 + + try: + await self._device.refresh() + + except UnauthorizedError: + await _login() + return except (ClientConnectionError, asyncio.TimeoutError): self._retry += 1 - if self._retry > RETRY: - self._attr_available = False + self._attr_available = self._retry <= RETRY + return except UnexpectedResponse: - pass + return + + self._attr_available = True + self._retry = 0 From 47cd368ed23fe0400c0c7a9acb1bf1c95d6347b9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 19 Nov 2023 10:41:48 +0100 Subject: [PATCH 578/982] New api endpoint for Trafikverket Weather (#104165) * New api endpoint for Trafikverket Weather * fix tests --- .../trafikverket_camera/manifest.json | 2 +- .../trafikverket_ferry/manifest.json | 2 +- .../trafikverket_train/config_flow.py | 3 - .../trafikverket_train/coordinator.py | 2 - .../trafikverket_train/manifest.json | 2 +- .../trafikverket_train/strings.json | 1 - .../trafikverket_weatherstation/manifest.json | 2 +- .../trafikverket_weatherstation/sensor.py | 74 ++----------------- .../trafikverket_weatherstation/strings.json | 44 +---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../trafikverket_train/test_config_flow.py | 9 --- 12 files changed, 15 insertions(+), 130 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index a679bd27d50..31eb911e24d 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.8"] + "requirements": ["pytrafikverket==0.3.9.1"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index a62c05a9baf..7f750c26c57 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.8"] + "requirements": ["pytrafikverket==0.3.9.1"] } diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index b7808dc38b2..df05942add1 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -9,7 +9,6 @@ from typing import Any from pytrafikverket import TrafikverketTrain from pytrafikverket.exceptions import ( InvalidAuthentication, - MultipleTrainAnnouncementFound, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, @@ -107,8 +106,6 @@ async def validate_input( errors["base"] = "more_stations" except NoTrainAnnouncementFound: errors["base"] = "no_trains" - except MultipleTrainAnnouncementFound: - errors["base"] = "multiple_trains" except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index ea852ab7fdf..91a7e9f07b2 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -8,7 +8,6 @@ import logging from pytrafikverket import TrafikverketTrain from pytrafikverket.exceptions import ( InvalidAuthentication, - MultipleTrainAnnouncementFound, NoTrainAnnouncementFound, UnknownError, ) @@ -112,7 +111,6 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): raise ConfigEntryAuthFailed from error except ( NoTrainAnnouncementFound, - MultipleTrainAnnouncementFound, UnknownError, ) as error: raise UpdateFailed( diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 8c23cb02258..b68a56b3793 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.8"] + "requirements": ["pytrafikverket==0.3.9.1"] } diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 78d69c880ae..a2c286867b2 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -10,7 +10,6 @@ "invalid_station": "Could not find a station with the specified name", "more_stations": "Found multiple stations with the specified name", "no_trains": "No train found", - "multiple_trains": "Multiple trains found", "incorrect_api_key": "Invalid API key for selected account" }, "step": { diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index d13eda72835..bd4b2b99b6a 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.8"] + "requirements": ["pytrafikverket==0.3.9.1"] } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 3ec7d137b6e..c1c54c7a4b7 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -24,48 +24,17 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.dt import as_utc from .const import ATTRIBUTION, CONF_STATION, DOMAIN, NONE_IS_ZERO_SENSORS from .coordinator import TVDataUpdateCoordinator -WIND_DIRECTIONS = [ - "east", - "north_east", - "east_south_east", - "north", - "north_north_east", - "north_north_west", - "north_west", - "south", - "south_east", - "south_south_west", - "south_west", - "west", -] -PRECIPITATION_AMOUNTNAME = [ - "error", - "mild_rain", - "moderate_rain", - "heavy_rain", - "mild_snow_rain", - "moderate_snow_rain", - "heavy_snow_rain", - "mild_snow", - "moderate_snow", - "heavy_snow", - "other", - "none", - "error", -] PRECIPITATION_TYPE = [ - "drizzle", - "hail", - "none", + "no", "rain", - "snow", - "rain_snow_mixed", "freezing_rain", + "snow", + "sleet", + "yes", ] @@ -103,8 +72,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="precipitation", translation_key="precipitation", - api_key="precipitationtype_translated", - name="Precipitation type", + api_key="precipitationtype", icon="mdi:weather-snowy-rainy", entity_registry_enabled_default=False, options=PRECIPITATION_TYPE, @@ -114,20 +82,10 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( key="wind_direction", translation_key="wind_direction", api_key="winddirection", - name="Wind direction", native_unit_of_measurement=DEGREE, icon="mdi:flag-triangle", state_class=SensorStateClass.MEASUREMENT, ), - TrafikverketSensorEntityDescription( - key="wind_direction_text", - translation_key="wind_direction_text", - api_key="winddirectiontext_translated", - name="Wind direction text", - icon="mdi:flag-triangle", - options=WIND_DIRECTIONS, - device_class=SensorDeviceClass.ENUM, - ), TrafikverketSensorEntityDescription( key="wind_speed", api_key="windforce", @@ -160,15 +118,6 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, state_class=SensorStateClass.MEASUREMENT, ), - TrafikverketSensorEntityDescription( - key="precipitation_amountname", - translation_key="precipitation_amountname", - api_key="precipitation_amountname_translated", - icon="mdi:weather-pouring", - entity_registry_enabled_default=False, - options=PRECIPITATION_AMOUNTNAME, - device_class=SensorDeviceClass.ENUM, - ), TrafikverketSensorEntityDescription( key="measure_time", translation_key="measure_time", @@ -195,12 +144,6 @@ async def async_setup_entry( ) -def _to_datetime(measuretime: str) -> datetime: - """Return isoformatted utc time.""" - time_obj = datetime.strptime(measuretime, "%Y-%m-%dT%H:%M:%S.%f%z") - return as_utc(time_obj) - - class TrafikverketWeatherStation( CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity ): @@ -246,10 +189,3 @@ class TrafikverketWeatherStation( if state is None and self.entity_description.key in NONE_IS_ZERO_SENSORS: return 0 return state - - @property - def available(self) -> bool: - """Return if entity is available.""" - if TYPE_CHECKING: - assert self.coordinator.data.active - return self.coordinator.data.active and super().available diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json index e7e279ba2d5..f2b1a98feac 100644 --- a/homeassistant/components/trafikverket_weatherstation/strings.json +++ b/homeassistant/components/trafikverket_weatherstation/strings.json @@ -35,56 +35,20 @@ "precipitation": { "name": "Precipitation type", "state": { - "drizzle": "Drizzle", - "hail": "Hail", - "none": "None", + "no": "No", "rain": "Rain", + "freezing_rain": "Freezing rain", "snow": "Snow", - "rain_snow_mixed": "Rain and snow mixed", - "freezing_rain": "Freezing rain" + "sleet": "Sleet", + "yes": "Yes" } }, "wind_direction": { "name": "Wind direction" }, - "wind_direction_text": { - "name": "Wind direction text", - "state": { - "east": "East", - "north_east": "North east", - "east_south_east": "East-south east", - "north": "North", - "north_north_east": "North-north east", - "north_north_west": "North-north west", - "north_west": "North west", - "south": "South", - "south_east": "South east", - "south_south_west": "South-south west", - "south_west": "South west", - "west": "West" - } - }, "wind_speed_max": { "name": "Wind speed max" }, - "precipitation_amountname": { - "name": "Precipitation name", - "state": { - "error": "Error", - "mild_rain": "Mild rain", - "moderate_rain": "Moderate rain", - "heavy_rain": "Heavy rain", - "mild_snow_rain": "Mild rain and snow mixed", - "moderate_snow_rain": "Moderate rain and snow mixed", - "heavy_snow_rain": "Heavy rain and snow mixed", - "mild_snow": "Mild snow", - "moderate_snow": "Moderate snow", - "heavy_snow": "Heavy snow", - "other": "Other", - "none": "None", - "unknown": "Unknown" - } - }, "measure_time": { "name": "Measure time" } diff --git a/requirements_all.txt b/requirements_all.txt index c67b3cc616c..7702c4e1276 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.8 +pytrafikverket==0.3.9.1 # homeassistant.components.v2c pytrydan==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc63f193584..648e4d53488 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1664,7 +1664,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.8 +pytrafikverket==0.3.9.1 # homeassistant.components.v2c pytrydan==0.4.0 diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 3493e031669..1accd4b5a55 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from pytrafikverket.exceptions import ( InvalidAuthentication, - MultipleTrainAnnouncementFound, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, @@ -177,10 +176,6 @@ async def test_flow_fails( NoTrainAnnouncementFound, "no_trains", ), - ( - MultipleTrainAnnouncementFound, - "multiple_trains", - ), ( UnknownError, "cannot_connect", @@ -371,10 +366,6 @@ async def test_reauth_flow_error( NoTrainAnnouncementFound, "no_trains", ), - ( - MultipleTrainAnnouncementFound, - "multiple_trains", - ), ( UnknownError, "cannot_connect", From 267bbaf4251b8b94026f548f92a4fe4e2fbac01e Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 19 Nov 2023 08:07:24 -0500 Subject: [PATCH 579/982] Bump aiosomecomfort to 0.0.22 (#104202) * Bump aiosomecomfort to 0.0.20 * Bump aiosomecomfort to 0.0.22 --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index a53eaaab8ce..47213476ad9 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.17"] + "requirements": ["AIOSomecomfort==0.0.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7702c4e1276..eb7d5dcac0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.6 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.17 +AIOSomecomfort==0.0.22 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 648e4d53488..6631d3cd200 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.6 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.17 +AIOSomecomfort==0.0.22 # homeassistant.components.adax Adax-local==0.1.5 From e7cec9b148351e7a4552bcf624cfa4e0cc1e92df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 08:14:55 -0600 Subject: [PATCH 580/982] Small speed up to constructing Bluetooth service_uuids (#104193) --- homeassistant/components/bluetooth/base_scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 8eacd3e291a..637ebbaf867 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -334,7 +334,7 @@ class BaseHaRemoteScanner(BaseHaScanner): local_name = prev_name if service_uuids and service_uuids != prev_service_uuids: - service_uuids = list(set(service_uuids + prev_service_uuids)) + service_uuids = list({*service_uuids, *prev_service_uuids}) elif not service_uuids: service_uuids = prev_service_uuids From d3b4dd226b8435c0aa74b320e829c0eb98eb0fc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 08:22:26 -0600 Subject: [PATCH 581/982] Prevent Bluetooth reconnects from blocking shutdown (#104150) --- homeassistant/components/bluetooth/manager.py | 3 +++ .../components/bluetooth/wrappers.py | 2 ++ tests/components/bluetooth/test_wrappers.py | 23 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 34edccaf4ab..ce047747a0c 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -124,6 +124,7 @@ class BluetoothManager: "storage", "slot_manager", "_debug", + "shutdown", ) def __init__( @@ -165,6 +166,7 @@ class BluetoothManager: self.storage = storage self.slot_manager = slot_manager self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + self.shutdown = False @property def supports_passive_scan(self) -> bool: @@ -259,6 +261,7 @@ class BluetoothManager: def async_stop(self, event: Event) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") + self.shutdown = True if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking() self._cancel_unavailable_tracking = None diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index bfcee9d25df..9de020f163e 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -270,6 +270,8 @@ class HaBleakClientWrapper(BleakClient): """Connect to the specified GATT server.""" assert models.MANAGER is not None manager = models.MANAGER + if manager.shutdown: + raise BleakError("Bluetooth is already shutdown") if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug("%s: Looking for backend to connect", self.__address) wrapped_backend = self._async_get_best_available_backend_and_device(manager) diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index de646f8ef9c..f69f8971479 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -7,6 +7,7 @@ from unittest.mock import patch import bleak from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from bleak.exc import BleakError import pytest from homeassistant.components.bluetooth import ( @@ -366,3 +367,25 @@ async def test_we_switch_adapters_on_failure( assert await client.connect() is False cancel_hci0() cancel_hci1() + + +async def test_raise_after_shutdown( + hass: HomeAssistant, + two_adapters: None, + enable_bluetooth: None, + install_bleak_catcher, + mock_platform_client_that_raises_on_connect, +) -> None: + """Ensure the slot gets released on connection exception.""" + manager = _get_manager() + hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices( + hass + ) + # hci0 has 2 slots, hci1 has 1 slot + with patch.object(manager, "shutdown", True): + ble_device = hci0_device_advs["00:00:00:00:00:01"][0] + client = bleak.BleakClient(ble_device) + with pytest.raises(BleakError, match="shutdown"): + await client.connect() + cancel_hci0() + cancel_hci1() From 51385dcaabc3d0ba167e66b4c05a79fe49a1e9e5 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sun, 19 Nov 2023 16:12:43 +0100 Subject: [PATCH 582/982] Deprecate calendar.list_events (#102481) * deprecate calendar.list_events * rename events to get_events * raise issue for use of deprecated service * Make issue fixable * Add fix_flow * Add service translation/yaml --- homeassistant/components/calendar/__init__.py | 43 ++++++- .../components/calendar/services.yaml | 16 +++ .../components/calendar/strings.json | 37 +++++- .../calendar/snapshots/test_init.ambr | 42 ++++++- tests/components/calendar/test_init.py | 114 +++++++++++++++--- 5 files changed, 224 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 2be0bd9a04b..5b98d372220 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -37,6 +37,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -261,8 +262,10 @@ CALENDAR_EVENT_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SERVICE_LIST_EVENTS: Final = "list_events" -SERVICE_LIST_EVENTS_SCHEMA: Final = vol.All( +LEGACY_SERVICE_LIST_EVENTS: Final = "list_events" +"""Deprecated: please use SERVICE_LIST_EVENTS.""" +SERVICE_GET_EVENTS: Final = "get_events" +SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( cv.has_at_least_one_key(EVENT_END_DATETIME, EVENT_DURATION), cv.has_at_most_one_key(EVENT_END_DATETIME, EVENT_DURATION), cv.make_entity_service_schema( @@ -301,11 +304,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: required_features=[CalendarEntityFeature.CREATE_EVENT], ) component.async_register_legacy_entity_service( - SERVICE_LIST_EVENTS, - SERVICE_LIST_EVENTS_SCHEMA, + LEGACY_SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS_SCHEMA, async_list_events_service, supports_response=SupportsResponse.ONLY, ) + component.async_register_entity_service( + SERVICE_GET_EVENTS, + SERVICE_GET_EVENTS_SCHEMA, + async_get_events_service, + supports_response=SupportsResponse.ONLY, + ) await component.async_setup(config) return True @@ -850,6 +859,32 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: async def async_list_events_service( calendar: CalendarEntity, service_call: ServiceCall +) -> ServiceResponse: + """List events on a calendar during a time range. + + Deprecated: please use async_get_events_service. + """ + _LOGGER.warning( + "Detected use of service 'calendar.list_events'. " + "This is deprecated and will stop working in Home Assistant 2024.6. " + "Use 'calendar.get_events' instead which supports multiple entities", + ) + async_create_issue( + calendar.hass, + DOMAIN, + "deprecated_service_calendar_list_events", + breaks_in_ha_version="2024.6.0", + is_fixable=True, + is_persistent=False, + issue_domain=calendar.platform.platform_name, + severity=IssueSeverity.WARNING, + translation_key="deprecated_service_calendar_list_events", + ) + return await async_get_events_service(calendar, service_call) + + +async def async_get_events_service( + calendar: CalendarEntity, service_call: ServiceCall ) -> ServiceResponse: """List events on a calendar during a time range.""" start = service_call.data.get(EVENT_START_DATETIME, dt_util.now()) diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 712d6ad8823..2e926fbdeed 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -52,3 +52,19 @@ list_events: duration: selector: duration: +get_events: + target: + entity: + domain: calendar + fields: + start_date_time: + example: "2022-03-22 20:00:00" + selector: + datetime: + end_date_time: + example: "2022-03-22 22:00:00" + selector: + datetime: + duration: + selector: + duration: diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 20679ed09b2..57450000199 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -72,9 +72,9 @@ } } }, - "list_events": { - "name": "List event", - "description": "Lists events on a calendar within a time range.", + "get_events": { + "name": "Get event", + "description": "Get events on a calendar within a time range.", "fields": { "start_date_time": { "name": "Start time", @@ -89,6 +89,37 @@ "description": "Returns active events from start_date_time until the specified duration." } } + }, + "list_events": { + "name": "List event", + "description": "Lists events on a calendar within a time range.", + "fields": { + "start_date_time": { + "name": "[%key:component::calendar::services::get_events::fields::start_date_time::name%]", + "description": "[%key:component::calendar::services::get_events::fields::start_date_time::description%]" + }, + "end_date_time": { + "name": "[%key:component::calendar::services::get_events::fields::end_date_time::name%]", + "description": "[%key:component::calendar::services::get_events::fields::end_date_time::description%]" + }, + "duration": { + "name": "[%key:component::calendar::services::get_events::fields::duration::name%]", + "description": "[%key:component::calendar::services::get_events::fields::duration::description%]" + } + } + } + }, + "issues": { + "deprecated_service_calendar_list_events": { + "title": "Detected use of deprecated service `calendar.list_events`", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::calendar::issues::deprecated_service_calendar_list_events::title%]", + "description": "Use `calendar.get_events` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **submit** to close this issue." + } + } + } } } } diff --git a/tests/components/calendar/snapshots/test_init.ambr b/tests/components/calendar/snapshots/test_init.ambr index 7d48228193a..67e8839f7a5 100644 --- a/tests/components/calendar/snapshots/test_init.ambr +++ b/tests/components/calendar/snapshots/test_init.ambr @@ -1,11 +1,34 @@ # serializer version: 1 -# name: test_list_events_service_duration[calendar.calendar_1-00:15:00] +# name: test_list_events_service_duration[calendar.calendar_1-00:15:00-get_events] + dict({ + 'calendar.calendar_1': dict({ + 'events': list([ + ]), + }), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_1-00:15:00-list_events] dict({ 'events': list([ ]), }) # --- -# name: test_list_events_service_duration[calendar.calendar_1-01:00:00] +# name: test_list_events_service_duration[calendar.calendar_1-01:00:00-get_events] + dict({ + 'calendar.calendar_1': dict({ + 'events': list([ + dict({ + 'description': 'Future Description', + 'end': '2023-10-19T08:20:05-07:00', + 'location': 'Future Location', + 'start': '2023-10-19T07:20:05-07:00', + 'summary': 'Future Event', + }), + ]), + }), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_1-01:00:00-list_events] dict({ 'events': list([ dict({ @@ -18,7 +41,20 @@ ]), }) # --- -# name: test_list_events_service_duration[calendar.calendar_2-00:15:00] +# name: test_list_events_service_duration[calendar.calendar_2-00:15:00-get_events] + dict({ + 'calendar.calendar_2': dict({ + 'events': list([ + dict({ + 'end': '2023-10-19T07:20:05-07:00', + 'start': '2023-10-19T06:20:05-07:00', + 'summary': 'Current Event', + }), + ]), + }), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_2-00:15:00-list_events] dict({ 'events': list([ dict({ diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index ad83d039d73..25804287172 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -12,9 +12,14 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.bootstrap import async_setup_component -from homeassistant.components.calendar import DOMAIN, SERVICE_LIST_EVENTS +from homeassistant.components.calendar import ( + DOMAIN, + LEGACY_SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.issue_registry import IssueRegistry import homeassistant.util.dt as dt_util from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -389,6 +394,41 @@ async def test_create_event_service_invalid_params( @freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.parametrize( + ("service", "expected"), + [ + ( + LEGACY_SERVICE_LIST_EVENTS, + { + "events": [ + { + "start": "2023-06-22T05:00:00-06:00", + "end": "2023-06-22T06:00:00-06:00", + "summary": "Future Event", + "description": "Future Description", + "location": "Future Location", + } + ] + }, + ), + ( + SERVICE_GET_EVENTS, + { + "calendar.calendar_1": { + "events": [ + { + "start": "2023-06-22T05:00:00-06:00", + "end": "2023-06-22T06:00:00-06:00", + "summary": "Future Event", + "description": "Future Description", + "location": "Future Location", + } + ] + } + }, + ), + ], +) @pytest.mark.parametrize( ("start_time", "end_time"), [ @@ -402,6 +442,8 @@ async def test_list_events_service( set_time_zone: None, start_time: str, end_time: str, + service: str, + expected: dict[str, Any], ) -> None: """Test listing events from the service call using exlplicit start and end time. @@ -414,8 +456,9 @@ async def test_list_events_service( response = await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, - { + service, + target={"entity_id": ["calendar.calendar_1"]}, + service_data={ "entity_id": "calendar.calendar_1", "start_date_time": start_time, "end_date_time": end_time, @@ -423,19 +466,16 @@ async def test_list_events_service( blocking=True, return_response=True, ) - assert response == { - "events": [ - { - "start": "2023-06-22T05:00:00-06:00", - "end": "2023-06-22T06:00:00-06:00", - "summary": "Future Event", - "description": "Future Description", - "location": "Future Location", - } - ] - } + assert response == expected +@pytest.mark.parametrize( + ("service"), + [ + (LEGACY_SERVICE_LIST_EVENTS), + SERVICE_GET_EVENTS, + ], +) @pytest.mark.parametrize( ("entity", "duration"), [ @@ -452,6 +492,7 @@ async def test_list_events_service_duration( hass: HomeAssistant, entity: str, duration: str, + service: str, snapshot: SnapshotAssertion, ) -> None: """Test listing events using a time duration.""" @@ -460,7 +501,7 @@ async def test_list_events_service_duration( response = await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, + service, { "entity_id": entity, "duration": duration, @@ -479,7 +520,7 @@ async def test_list_events_positive_duration(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid, match="should be positive"): await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS, { "entity_id": "calendar.calendar_1", "duration": "-01:00:00", @@ -499,7 +540,7 @@ async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid, match="at most one of"): await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS, { "entity_id": "calendar.calendar_1", "end_date_time": end, @@ -518,10 +559,47 @@ async def test_list_events_missing_fields(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid, match="at least one of"): await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS, { "entity_id": "calendar.calendar_1", }, blocking=True, return_response=True, ) + + +async def test_issue_deprecated_service_calendar_list_events( + hass: HomeAssistant, + issue_registry: IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the issue is raised on deprecated service weather.get_forecast.""" + + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + _ = await hass.services.async_call( + DOMAIN, + LEGACY_SERVICE_LIST_EVENTS, + target={"entity_id": ["calendar.calendar_1"]}, + service_data={ + "entity_id": "calendar.calendar_1", + "duration": "01:00:00", + }, + blocking=True, + return_response=True, + ) + + issue = issue_registry.async_get_issue( + "calendar", "deprecated_service_calendar_list_events" + ) + assert issue + assert issue.issue_domain == "demo" + assert issue.issue_id == "deprecated_service_calendar_list_events" + assert issue.translation_key == "deprecated_service_calendar_list_events" + + assert ( + "Detected use of service 'calendar.list_events'. " + "This is deprecated and will stop working in Home Assistant 2024.6. " + "Use 'calendar.get_events' instead which supports multiple entities" + ) in caplog.text From 9e0bc9e2526af99c81dde7ccf9fb3bd28224ba7d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 19 Nov 2023 18:55:30 +0100 Subject: [PATCH 583/982] Reolink update current firmware state after install attempt (#104210) --- homeassistant/components/reolink/update.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 1c10671550d..a75af46e81e 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -98,3 +98,5 @@ class ReolinkUpdateEntity( raise HomeAssistantError( f"Error trying to update Reolink firmware: {err}" ) from err + finally: + self.async_write_ha_state() From ec069fbebface025b5fd059cf4aa0ef5b166f916 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 19 Nov 2023 19:27:03 +0100 Subject: [PATCH 584/982] Change name of universal media player to sentence case (#104204) --- homeassistant/components/universal/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/universal/manifest.json b/homeassistant/components/universal/manifest.json index 587d2c7aad2..4cf52892aaf 100644 --- a/homeassistant/components/universal/manifest.json +++ b/homeassistant/components/universal/manifest.json @@ -1,6 +1,6 @@ { "domain": "universal", - "name": "Universal Media Player", + "name": "Universal media player", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/universal", "iot_class": "calculated", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 228cc6fa5f5..bdc12cceb8e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6098,7 +6098,7 @@ "iot_class": "cloud_polling" }, "universal": { - "name": "Universal Media Player", + "name": "Universal media player", "integration_type": "hub", "config_flow": false, "iot_class": "calculated" From 68f8b2cab5ee68156072ebb831b4dd6300107b67 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Nov 2023 19:50:25 +0100 Subject: [PATCH 585/982] Fix mqtt json light allows to set brightness value >255 (#104220) --- .../components/mqtt/light/schema_json.py | 11 +++++++---- tests/components/mqtt/test_light_json.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 6f70ff34051..2a2a262be36 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -367,10 +367,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: - self._attr_brightness = int( - brightness # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 + self._attr_brightness = min( + int( + brightness # type: ignore[operator] + / float(self._config[CONF_BRIGHTNESS_SCALE]) + * 255 + ), + 255, ) else: _LOGGER.debug( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index e7471829856..b3dd3a9a4e3 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1785,6 +1785,24 @@ async def test_brightness_scale( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 + # Turn on the light with half brightness + async_fire_mqtt_message( + hass, "test_light_bright_scale", '{"state":"ON", "brightness": 50}' + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 128 + + # Test limmiting max brightness + async_fire_mqtt_message( + hass, "test_light_bright_scale", '{"state":"ON", "brightness": 103}' + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + @pytest.mark.parametrize( "hass_config", From 1ca95965b675d020062687ae50f4f4aad75b2612 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 19 Nov 2023 20:01:08 +0100 Subject: [PATCH 586/982] Bump reolink_aio to 0.8.0 (#104211) --- 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 58785c1d795..56a2408eff5 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.7.15"] + "requirements": ["reolink-aio==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index eb7d5dcac0b..5dd31ad2189 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2336,7 +2336,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.15 +reolink-aio==0.8.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6631d3cd200..aa4e1f79fec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1742,7 +1742,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.15 +reolink-aio==0.8.0 # homeassistant.components.rflink rflink==0.0.65 From 9a38e23f28837cb58daf0af7573275c3c093bf78 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Nov 2023 20:15:02 +0100 Subject: [PATCH 587/982] Fix imap does not decode text body correctly (#104217) --- homeassistant/components/imap/coordinator.py | 26 ++++- tests/components/imap/const.py | 99 ++++++++++++++++---- tests/components/imap/test_init.py | 51 +++++++++- 3 files changed, 153 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 59c24b11e51..d77f7fb05bb 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -6,6 +6,7 @@ from collections.abc import Mapping from datetime import datetime, timedelta import email from email.header import decode_header, make_header +from email.message import Message from email.utils import parseaddr, parsedate_to_datetime import logging from typing import Any @@ -96,8 +97,9 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: class ImapMessage: """Class to parse an RFC822 email message.""" - def __init__(self, raw_message: bytes) -> None: + def __init__(self, raw_message: bytes, charset: str = "utf-8") -> None: """Initialize IMAP message.""" + self._charset = charset self.email_message = email.message_from_bytes(raw_message) @property @@ -157,18 +159,30 @@ class ImapMessage: message_html: str | None = None message_untyped_text: str | None = None + def _decode_payload(part: Message) -> str: + """Try to decode text payloads. + + Common text encodings are quoted-printable or base64. + Falls back to the raw content part if decoding fails. + """ + try: + return str(part.get_payload(decode=True).decode(self._charset)) + except Exception: # pylint: disable=broad-except + return str(part.get_payload()) + + part: Message for part in self.email_message.walk(): if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: if message_text is None: - message_text = part.get_payload() + message_text = _decode_payload(part) elif part.get_content_type() == "text/html": if message_html is None: - message_html = part.get_payload() + message_html = _decode_payload(part) elif ( part.get_content_type().startswith("text") and message_untyped_text is None ): - message_untyped_text = part.get_payload() + message_untyped_text = str(part.get_payload()) if message_text is not None: return message_text @@ -223,7 +237,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Send a event for the last message if the last message was changed.""" response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]") if response.result == "OK": - message = ImapMessage(response.lines[1]) + message = ImapMessage( + response.lines[1], charset=self.config_entry.data[CONF_CHARSET] + ) # Set `initial` to `False` if the last message is triggered again initial: bool = True if (message_id := message.message_id) == self._last_message_id: diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index ec864fd4665..713261936c7 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -18,16 +18,25 @@ TEST_MESSAGE_HEADERS1 = ( b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" ) TEST_MESSAGE_HEADERS2 = ( - b"MIME-Version: 1.0\r\n" b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" - b"Message-ID: " + b"Message-ID: \r\n" + b"MIME-Version: 1.0\r\n" +) + +TEST_MULTIPART_HEADER = ( + b'Content-Type: multipart/related;\r\n\tboundary="Mark=_100584970350292485166"' ) TEST_MESSAGE_HEADERS3 = b"" TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 + +TEST_MESSAGE_MULTIPART = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 + TEST_MULTIPART_HEADER +) + TEST_MESSAGE_NO_SUBJECT_TO_FROM = ( TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS3 ) @@ -44,21 +53,27 @@ TEST_INVALID_DATE3 = ( TEST_CONTENT_TEXT_BARE = b"\r\nTest body\r\n\r\n" -TEST_CONTENT_BINARY = ( - b"Content-Type: application/binary\r\n" - b"Content-Transfer-Encoding: base64\r\n" - b"\r\n" - b"VGVzdCBib2R5\r\n" -) +TEST_CONTENT_BINARY = b"Content-Type: application/binary\r\n\r\nTest body\r\n" TEST_CONTENT_TEXT_PLAIN = ( - b"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n" - b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n" ) +TEST_CONTENT_TEXT_BASE64 = ( + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: base64\r\n\r\nVGVzdCBib2R5\r\n" +) + +TEST_CONTENT_TEXT_BASE64_INVALID = ( + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: base64\r\n\r\nVGVzdCBib2R5invalid\r\n" +) +TEST_BADLY_ENCODED_CONTENT = "VGVzdCBib2R5invalid\r\n" + TEST_CONTENT_TEXT_OTHER = ( b"Content-Type: text/other; charset=UTF-8\r\n" - b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n" ) TEST_CONTENT_HTML = ( @@ -76,14 +91,40 @@ TEST_CONTENT_HTML = ( b"\r\n" b"\r\n" ) +TEST_CONTENT_HTML_BASE64 = ( + b"Content-Type: text/html; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: base64\r\n\r\n" + b"PGh0bWw+CiAgICA8aGVhZD48bWV0YSBodHRwLWVxdW" + b"l2PSJjb250ZW50LXR5cGUiIGNvbnRlbnQ9InRleHQvaHRtbDsgY2hhcnNldD1VVEYtOCI+PC9oZWFkPgog" + b"CAgPGJvZHk+CiAgICAgIDxwPlRlc3QgYm9keTxicj48L3A+CiAgICA8L2JvZHk+CjwvaHRtbD4=\r\n" +) + TEST_CONTENT_MULTIPART = ( b"\r\nThis is a multi-part message in MIME format.\r\n" - + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + TEST_CONTENT_TEXT_PLAIN - + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + TEST_CONTENT_HTML - + b"--------------McwBciN2C0o3rWeF1tmFo2oI--\r\n" + + b"\r\n--Mark=_100584970350292485166--\r\n" +) + +TEST_CONTENT_MULTIPART_BASE64 = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_TEXT_BASE64 + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_HTML_BASE64 + + b"\r\n--Mark=_100584970350292485166--\r\n" +) + +TEST_CONTENT_MULTIPART_BASE64_INVALID = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_TEXT_BASE64_INVALID + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_HTML_BASE64 + + b"\r\n--Mark=_100584970350292485166--\r\n" ) EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) @@ -202,14 +243,40 @@ TEST_FETCH_RESPONSE_MULTIPART = ( "OK", [ b"1 FETCH (BODY[] {" - + str(len(TEST_MESSAGE + TEST_CONTENT_MULTIPART)).encode("utf-8") + + str(len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART)).encode("utf-8") + b"}", - bytearray(TEST_MESSAGE + TEST_CONTENT_MULTIPART), + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) +TEST_FETCH_RESPONSE_MULTIPART_BASE64 = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64)).encode( + "utf-8" + ) + + b"}", + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64), b")", b"Fetch completed (0.0001 + 0.000 secs).", ], ) +TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str( + len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64_INVALID) + ).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64_INVALID), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM = ( "OK", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index ceda841202c..a00f9d9c25d 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -17,12 +17,15 @@ from homeassistant.util.dt import utcnow from .const import ( BAD_RESPONSE, EMPTY_SEARCH_RESPONSE, + TEST_BADLY_ENCODED_CONTENT, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, TEST_FETCH_RESPONSE_INVALID_DATE1, TEST_FETCH_RESPONSE_INVALID_DATE2, TEST_FETCH_RESPONSE_INVALID_DATE3, TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_MULTIPART_BASE64, + TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -110,6 +113,7 @@ async def test_entry_startup_fails( (TEST_FETCH_RESPONSE_TEXT_OTHER, True), (TEST_FETCH_RESPONSE_HTML, True), (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_MULTIPART_BASE64, True), (TEST_FETCH_RESPONSE_BINARY, True), ], ids=[ @@ -122,6 +126,7 @@ async def test_entry_startup_fails( "other", "html", "multipart", + "multipart_base64", "binary", ], ) @@ -154,7 +159,7 @@ async def test_receiving_message_successfully( assert data["folder"] == "INBOX" assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" - assert data["text"] + assert "Test body" in data["text"] assert ( valid_date and isinstance(data["date"], datetime) @@ -163,6 +168,48 @@ async def test_receiving_message_successfully( ) +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + ("imap_fetch"), + [ + TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, + ], + ids=[ + "multipart_base64_invalid", + ], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_receiving_message_with_invalid_encoding( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] == TEST_BADLY_ENCODED_CONTENT + + @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize("imap_fetch", [TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM]) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @@ -196,7 +243,7 @@ async def test_receiving_message_no_subject_to_from( assert data["date"] == datetime( 2023, 3, 24, 13, 52, tzinfo=timezone(timedelta(seconds=3600)) ) - assert data["text"] == "Test body\r\n\r\n" + assert data["text"] == "Test body\r\n" assert data["headers"]["Return-Path"] == ("",) assert data["headers"]["Delivered-To"] == ("notify@example.com",) From 25cc4df45542b79e9946676df1ba85ccb526e106 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 19 Nov 2023 11:26:58 -0800 Subject: [PATCH 588/982] Fix Local To-do list bug renaming items (#104182) * Fix Local To-do bug renaming items * Fix renaming --- homeassistant/components/local_todo/todo.py | 4 +- tests/components/local_todo/test_todo.py | 48 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index f9832ad8730..cd30c2eeebe 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -63,9 +63,11 @@ def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" result: dict[str, str] = {} for name, value in obj: + if value is None: + continue if name == "status": result[name] = ICS_TODO_STATUS_MAP_INV[value] - elif value is not None: + else: result[name] = value return result diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 39e9264d45a..5747e05ad05 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -237,6 +237,54 @@ async def test_update_item( assert state.state == "0" +async def test_rename( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test renaming a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": item["uid"], "rename": "water"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item has been renamed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "water" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + @pytest.mark.parametrize( ("src_idx", "dst_idx", "expected_items"), [ From 091559d1473909b3ff57a410e9acb6ac3f62a4d5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 19 Nov 2023 20:34:41 +0100 Subject: [PATCH 589/982] Add new sensors to Trafikverket Weather (#104199) --- .../trafikverket_weatherstation/const.py | 10 -- .../trafikverket_weatherstation/sensor.py | 119 ++++++++++++++---- .../trafikverket_weatherstation/strings.json | 28 ++++- 3 files changed, 120 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/trafikverket_weatherstation/const.py b/homeassistant/components/trafikverket_weatherstation/const.py index 0d4680e9b37..34c18359ee4 100644 --- a/homeassistant/components/trafikverket_weatherstation/const.py +++ b/homeassistant/components/trafikverket_weatherstation/const.py @@ -5,13 +5,3 @@ DOMAIN = "trafikverket_weatherstation" CONF_STATION = "station" PLATFORMS = [Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" - -NONE_IS_ZERO_SENSORS = { - "air_temp", - "road_temp", - "wind_direction", - "wind_speed", - "wind_speed_max", - "humidity", - "precipitation_amount", -} diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index c1c54c7a4b7..607a230fbbe 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1,9 +1,11 @@ """Weather information for air and road temperature (by Trafikverket).""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING + +from pytrafikverket.trafikverket_weather import WeatherStationInfo from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,6 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, + UnitOfLength, UnitOfSpeed, UnitOfTemperature, UnitOfVolumetricFlux, @@ -24,8 +27,9 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, CONF_STATION, DOMAIN, NONE_IS_ZERO_SENSORS +from .const import ATTRIBUTION, CONF_STATION, DOMAIN from .coordinator import TVDataUpdateCoordinator PRECIPITATION_TYPE = [ @@ -42,7 +46,7 @@ PRECIPITATION_TYPE = [ class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" - api_key: str + value_fn: Callable[[WeatherStationInfo], StateType | datetime] @dataclass @@ -52,11 +56,18 @@ class TrafikverketSensorEntityDescription( """Describes Trafikverket sensor entity.""" +def add_utc_timezone(date_time: datetime | None) -> datetime | None: + """Add UTC timezone if datetime.""" + if date_time: + return date_time.replace(tzinfo=dt_util.UTC) + return None + + SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="air_temp", translation_key="air_temperature", - api_key="air_temp", + value_fn=lambda data: data.air_temp or 0, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -64,7 +75,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="road_temp", translation_key="road_temperature", - api_key="road_temp", + value_fn=lambda data: data.road_temp or 0, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -72,7 +83,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="precipitation", translation_key="precipitation", - api_key="precipitationtype", + value_fn=lambda data: data.precipitationtype, icon="mdi:weather-snowy-rainy", entity_registry_enabled_default=False, options=PRECIPITATION_TYPE, @@ -81,14 +92,14 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="wind_direction", translation_key="wind_direction", - api_key="winddirection", + value_fn=lambda data: data.winddirection, native_unit_of_measurement=DEGREE, icon="mdi:flag-triangle", state_class=SensorStateClass.MEASUREMENT, ), TrafikverketSensorEntityDescription( key="wind_speed", - api_key="windforce", + value_fn=lambda data: data.windforce or 0, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -96,7 +107,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="wind_speed_max", translation_key="wind_speed_max", - api_key="windforcemax", + value_fn=lambda data: data.windforcemax or 0, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy-variant", @@ -105,7 +116,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="humidity", - api_key="humidity", + value_fn=lambda data: data.humidity or 0, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, @@ -113,7 +124,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="precipitation_amount", - api_key="precipitation_amount", + value_fn=lambda data: data.precipitation_amount or 0, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, state_class=SensorStateClass.MEASUREMENT, @@ -121,7 +132,77 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="measure_time", translation_key="measure_time", - api_key="measure_time", + value_fn=lambda data: data.measure_time, + icon="mdi:clock", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + ), + TrafikverketSensorEntityDescription( + key="dew_point", + translation_key="dew_point", + value_fn=lambda data: data.dew_point or 0, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TrafikverketSensorEntityDescription( + key="visible_distance", + translation_key="visible_distance", + value_fn=lambda data: data.visible_distance, + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="road_ice_depth", + translation_key="road_ice_depth", + value_fn=lambda data: data.road_ice_depth, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="road_snow_depth", + translation_key="road_snow_depth", + value_fn=lambda data: data.road_snow_depth, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="road_water_depth", + translation_key="road_water_depth", + value_fn=lambda data: data.road_water_depth, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="road_water_equivalent_depth", + translation_key="road_water_equivalent_depth", + value_fn=lambda data: data.road_water_equivalent_depth, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="wind_height", + translation_key="wind_height", + value_fn=lambda data: data.wind_height, + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="modified_time", + translation_key="modified_time", + value_fn=lambda data: add_utc_timezone(data.modified_time), icon="mdi:clock", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, @@ -176,16 +257,4 @@ class TrafikverketWeatherStation( @property def native_value(self) -> StateType | datetime: """Return state of sensor.""" - if self.entity_description.api_key == "measure_time": - if TYPE_CHECKING: - assert self.coordinator.data.measure_time - return self.coordinator.data.measure_time - - state: StateType = getattr( - self.coordinator.data, self.entity_description.api_key - ) - - # For zero value state the api reports back None for certain sensors. - if state is None and self.entity_description.key in NONE_IS_ZERO_SENSORS: - return 0 - return state + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json index f2b1a98feac..a4838dab0e2 100644 --- a/homeassistant/components/trafikverket_weatherstation/strings.json +++ b/homeassistant/components/trafikverket_weatherstation/strings.json @@ -35,12 +35,12 @@ "precipitation": { "name": "Precipitation type", "state": { - "no": "No", + "no": "None", "rain": "Rain", "freezing_rain": "Freezing rain", "snow": "Snow", "sleet": "Sleet", - "yes": "Yes" + "yes": "Yes (unknown)" } }, "wind_direction": { @@ -51,6 +51,30 @@ }, "measure_time": { "name": "Measure time" + }, + "dew_point": { + "name": "Dew point" + }, + "visible_distance": { + "name": "Visible distance" + }, + "road_ice_depth": { + "name": "Ice depth on road" + }, + "road_snow_depth": { + "name": "Snow depth on road" + }, + "road_water_depth": { + "name": "Water depth on road" + }, + "road_water_equivalent_depth": { + "name": "Water equivalent depth on road" + }, + "wind_height": { + "name": "Wind measurement height" + }, + "modified_time": { + "name": "Data modified time" } } } From 41224f16741d1a260f928af891f5ef87e1a2221f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 19 Nov 2023 20:43:31 +0100 Subject: [PATCH 590/982] Add Reolink firmware version for IPC cams (#104212) --- homeassistant/components/reolink/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index e7d62c9705a..4b9689e2652 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -87,5 +87,6 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), manufacturer=self._host.api.manufacturer, + sw_version=self._host.api.camera_sw_version(dev_ch), configuration_url=self._conf_url, ) From 173f4760bc4beccdf1886ae8ca0fb8ae2af9cff3 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sun, 19 Nov 2023 20:44:02 +0100 Subject: [PATCH 591/982] Deprecate weather.get_forecast (#102534) * Deprecate weather.get_forecast * Rename forecast to get_forecasts * raise issue for use of deprecated service * Add fix_flow * Add service translation/yaml --- homeassistant/components/weather/__init__.py | 43 +- .../components/weather/services.yaml | 18 + homeassistant/components/weather/strings.json | 25 +- .../accuweather/snapshots/test_weather.ambr | 232 + tests/components/accuweather/test_weather.py | 15 +- .../aemet/snapshots/test_weather.ambr | 1448 ++ tests/components/aemet/test_weather.py | 15 +- .../ipma/snapshots/test_weather.ambr | 119 + tests/components/ipma/test_weather.py | 15 +- .../met_eireann/snapshots/test_weather.ambr | 104 + tests/components/met_eireann/test_weather.py | 15 +- .../metoffice/snapshots/test_weather.ambr | 1982 +++ tests/components/metoffice/test_weather.py | 21 +- .../nws/snapshots/test_weather.ambr | 303 + tests/components/nws/test_weather.py | 24 +- .../smhi/snapshots/test_weather.ambr | 412 + tests/components/smhi/test_weather.py | 13 +- .../template/snapshots/test_weather.ambr | 283 + tests/components/template/test_weather.py | 90 +- .../tomorrowio/snapshots/test_weather.ambr | 1124 ++ tests/components/tomorrowio/test_weather.py | 54 +- .../weather/snapshots/test_init.ambr | 52 +- tests/components/weather/test_init.py | 99 +- .../weatherkit/snapshots/test_weather.ambr | 12266 ++++++++++++++++ tests/components/weatherkit/test_weather.py | 30 +- 25 files changed, 18726 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index d04daf2b160..3d9eccd9425 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -135,7 +135,9 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 -SERVICE_GET_FORECAST: Final = "get_forecast" +LEGACY_SERVICE_GET_FORECAST: Final = "get_forecast" +"""Deprecated: please use SERVICE_GET_FORECASTS.""" +SERVICE_GET_FORECASTS: Final = "get_forecasts" _ObservationUpdateCoordinatorT = TypeVar( "_ObservationUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" @@ -211,7 +213,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) component.async_register_legacy_entity_service( - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, async_get_forecast_service, required_features=[ @@ -221,6 +223,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ], supports_response=SupportsResponse.ONLY, ) + component.async_register_entity_service( + SERVICE_GET_FORECASTS, + {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, + async_get_forecasts_service, + required_features=[ + WeatherEntityFeature.FORECAST_DAILY, + WeatherEntityFeature.FORECAST_HOURLY, + WeatherEntityFeature.FORECAST_TWICE_DAILY, + ], + supports_response=SupportsResponse.ONLY, + ) async_setup_ws_api(hass) await component.async_setup(config) return True @@ -1086,6 +1099,32 @@ def raise_unsupported_forecast(entity_id: str, forecast_type: str) -> None: async def async_get_forecast_service( weather: WeatherEntity, service_call: ServiceCall +) -> ServiceResponse: + """Get weather forecast. + + Deprecated: please use async_get_forecasts_service. + """ + _LOGGER.warning( + "Detected use of service 'weather.get_forecast'. " + "This is deprecated and will stop working in Home Assistant 2024.6. " + "Use 'weather.get_forecasts' instead which supports multiple entities", + ) + ir.async_create_issue( + weather.hass, + DOMAIN, + "deprecated_service_weather_get_forecast", + breaks_in_ha_version="2024.6.0", + is_fixable=True, + is_persistent=False, + issue_domain=weather.platform.platform_name, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_service_weather_get_forecast", + ) + return await async_get_forecasts_service(weather, service_call) + + +async def async_get_forecasts_service( + weather: WeatherEntity, service_call: ServiceCall ) -> ServiceResponse: """Get weather forecast.""" forecast_type = service_call.data["type"] diff --git a/homeassistant/components/weather/services.yaml b/homeassistant/components/weather/services.yaml index b2b71396fab..222dbf596d0 100644 --- a/homeassistant/components/weather/services.yaml +++ b/homeassistant/components/weather/services.yaml @@ -16,3 +16,21 @@ get_forecast: - "hourly" - "twice_daily" translation_key: forecast_type +get_forecasts: + target: + entity: + domain: weather + supported_features: + - weather.WeatherEntityFeature.FORECAST_DAILY + - weather.WeatherEntityFeature.FORECAST_HOURLY + - weather.WeatherEntityFeature.FORECAST_TWICE_DAILY + fields: + type: + required: true + selector: + select: + options: + - "daily" + - "hourly" + - "twice_daily" + translation_key: forecast_type diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index f76e93c66c3..0b712a4de05 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -88,13 +88,23 @@ } }, "services": { + "get_forecasts": { + "name": "Get forecasts", + "description": "Get weather forecasts.", + "fields": { + "type": { + "name": "Forecast type", + "description": "Forecast type: daily, hourly or twice daily." + } + } + }, "get_forecast": { "name": "Get forecast", "description": "Get weather forecast.", "fields": { "type": { - "name": "Forecast type", - "description": "Forecast type: daily, hourly or twice daily." + "name": "[%key:component::weather::services::get_forecasts::fields::type::name%]", + "description": "[%key:component::weather::services::get_forecasts::fields::type::description%]" } } } @@ -107,6 +117,17 @@ "deprecated_weather_forecast_no_url": { "title": "[%key:component::weather::issues::deprecated_weather_forecast_url::title%]", "description": "The custom integration `{platform}` implements the `forecast` property or sets `self._attr_forecast` in a subclass of WeatherEntity.\n\nPlease report it to the author of the {platform} integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." + }, + "deprecated_service_weather_get_forecast": { + "title": "Detected use of deprecated service `weather.get_forecast`", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]", + "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **submit** to close this issue." + } + } + } } } } diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr index 521393af71b..081e7bf595a 100644 --- a/tests/components/accuweather/snapshots/test_weather.ambr +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -75,6 +75,238 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription list([ dict({ diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 5a35f2798d8..920e5cf82b9 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import PropertyMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.accuweather.const import ATTRIBUTION @@ -31,7 +32,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, WeatherEntityFeature, ) from homeassistant.const import ( @@ -206,16 +208,24 @@ async def test_unsupported_condition_icon_data(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_FORECAST_CONDITION) is None +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" await init_integration(hass, forecast=True) response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.home", "type": "daily", @@ -223,7 +233,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr index 08cc379267d..9a7b79d94ea 100644 --- a/tests/components/aemet/snapshots/test_weather.ambr +++ b/tests/components/aemet/snapshots/test_weather.ambr @@ -490,6 +490,1454 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.aemet': dict({ + 'forecast': list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.aemet': dict({ + 'forecast': list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.aemet': dict({ + 'forecast': list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.aemet': dict({ + 'forecast': list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription[daily] list([ dict({ diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index f7ab39b9a71..695087bb738 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -29,7 +29,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant @@ -122,10 +123,18 @@ async def test_aemet_weather_legacy( assert state is None +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" @@ -135,7 +144,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.aemet", "type": "daily", @@ -147,7 +156,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.aemet", "type": "hourly", diff --git a/tests/components/ipma/snapshots/test_weather.ambr b/tests/components/ipma/snapshots/test_weather.ambr index 92e1d1a91b5..0a778776329 100644 --- a/tests/components/ipma/snapshots/test_weather.ambr +++ b/tests/components/ipma/snapshots/test_weather.ambr @@ -36,6 +36,125 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.hometown': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 16, 0, 0), + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.hometown': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 16, 0, 0), + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.hometown': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 16, 0, 0), + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.hometown': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription[daily] list([ dict({ diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index 71884e0c82e..9e0262733a3 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -22,7 +22,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -152,9 +153,17 @@ async def test_failed_get_observation_forecast(hass: HomeAssistant) -> None: assert state.attributes.get("friendly_name") == "HomeTown" +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" @@ -169,7 +178,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.hometown", "type": "daily", @@ -181,7 +190,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.hometown", "type": "hourly", diff --git a/tests/components/met_eireann/snapshots/test_weather.ambr b/tests/components/met_eireann/snapshots/test_weather.ambr index 81d7a52aa06..90f36d09d25 100644 --- a/tests/components/met_eireann/snapshots/test_weather.ambr +++ b/tests/components/met_eireann/snapshots/test_weather.ambr @@ -31,6 +31,110 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.somewhere': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.somewhere': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.somewhere': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.somewhere': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription[daily] list([ dict({ diff --git a/tests/components/met_eireann/test_weather.py b/tests/components/met_eireann/test_weather.py index dce1bff1c7c..e5c2c66b626 100644 --- a/tests/components/met_eireann/test_weather.py +++ b/tests/components/met_eireann/test_weather.py @@ -9,7 +9,8 @@ from homeassistant.components.met_eireann import UPDATE_INTERVAL from homeassistant.components.met_eireann.const import DOMAIN from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -77,10 +78,18 @@ async def test_weather(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 0 +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, mock_weather, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" mock_weather.get_forecast.return_value = [ @@ -102,7 +111,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity_id, "type": "daily", @@ -114,7 +123,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity_id, "type": "hourly", diff --git a/tests/components/metoffice/snapshots/test_weather.ambr b/tests/components/metoffice/snapshots/test_weather.ambr index 38df9f04ab2..108a9330403 100644 --- a/tests/components/metoffice/snapshots/test_weather.ambr +++ b/tests/components/metoffice/snapshots/test_weather.ambr @@ -647,6 +647,1988 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].2 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].3 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].4 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].4 + dict({ + 'forecast': list([ + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].2 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].3 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].4 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + ]), + }), + }) +# --- # name: test_forecast_subscription[weather.met_office_wavertree_3_hourly] list([ dict({ diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index b87a63a5a46..19c27873d5e 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -13,7 +13,8 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.metoffice.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -421,6 +422,13 @@ async def test_legacy_config_entry( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -428,6 +436,7 @@ async def test_forecast_service( snapshot: SnapshotAssertion, no_sensor, wavertree_data: dict[str, _Matcher], + service: str, ) -> None: """Test multiple forecast.""" entry = MockConfigEntry( @@ -444,7 +453,7 @@ async def test_forecast_service( for forecast_type in ("daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.met_office_wavertree_daily", "type": forecast_type, @@ -452,7 +461,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # Calling the services should use cached data @@ -470,7 +478,7 @@ async def test_forecast_service( for forecast_type in ("daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.met_office_wavertree_daily", "type": forecast_type, @@ -478,7 +486,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # Calling the services should update the hourly forecast @@ -494,7 +501,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.met_office_wavertree_daily", "type": "hourly", @@ -502,7 +509,7 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] == [] + assert response == snapshot @pytest.mark.parametrize( diff --git a/tests/components/nws/snapshots/test_weather.ambr b/tests/components/nws/snapshots/test_weather.ambr index 0dddca954be..0db2311085c 100644 --- a/tests/components/nws/snapshots/test_weather.ambr +++ b/tests/components/nws/snapshots/test_weather.ambr @@ -103,6 +103,309 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].2 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].3 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].4 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].5 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].4 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].5 + dict({ + 'forecast': list([ + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].2 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].3 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].4 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].5 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + ]), + }), + }) +# --- # name: test_forecast_subscription[hourly-weather.abc_daynight] list([ dict({ diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 54069eec02c..c7478be7c07 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -13,7 +13,8 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -400,12 +401,20 @@ async def test_legacy_config_entry(hass: HomeAssistant, no_sensor) -> None: assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, mock_simple_nws, no_sensor, + service: str, ) -> None: """Test multiple forecast.""" instance = mock_simple_nws.return_value @@ -425,7 +434,7 @@ async def test_forecast_service( for forecast_type in ("twice_daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.abc_daynight", "type": forecast_type, @@ -433,7 +442,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # Calling the services should use cached data @@ -453,7 +461,7 @@ async def test_forecast_service( for forecast_type in ("twice_daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.abc_daynight", "type": forecast_type, @@ -461,7 +469,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # Calling the services should update the hourly forecast @@ -477,7 +484,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.abc_daynight", "type": "hourly", @@ -485,7 +492,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # after additional 35 minutes data caching expires, data is no longer shown @@ -495,7 +501,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.abc_daynight", "type": "hourly", @@ -503,7 +509,7 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] == [] + assert response == snapshot @pytest.mark.parametrize( diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index ade151ed128..fa9d76c68ba 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -195,6 +195,418 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00', + 'humidity': 95, + 'precipitation': 6.3, + 'pressure': 1001.0, + 'temperature': 12.0, + 'templow': 11.0, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00', + 'humidity': 75, + 'precipitation': 4.8, + 'pressure': 1011.0, + 'temperature': 14.0, + 'templow': 10.0, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00', + 'humidity': 69, + 'precipitation': 0.6, + 'pressure': 1015.0, + 'temperature': 18.0, + 'templow': 12.0, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 17.0, + 'templow': 12.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1015.0, + 'temperature': 21.0, + 'templow': 14.0, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00', + 'humidity': 64, + 'precipitation': 3.6, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00', + 'humidity': 61, + 'precipitation': 2.4, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00', + 'humidity': 95, + 'precipitation': 6.3, + 'pressure': 1001.0, + 'temperature': 12.0, + 'templow': 11.0, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00', + 'humidity': 75, + 'precipitation': 4.8, + 'pressure': 1011.0, + 'temperature': 14.0, + 'templow': 10.0, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00', + 'humidity': 69, + 'precipitation': 0.6, + 'pressure': 1015.0, + 'temperature': 18.0, + 'templow': 12.0, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 17.0, + 'templow': 12.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1015.0, + 'temperature': 21.0, + 'templow': 14.0, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00', + 'humidity': 64, + 'precipitation': 3.6, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00', + 'humidity': 61, + 'precipitation': 2.4, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00', + 'humidity': 95, + 'precipitation': 6.3, + 'pressure': 1001.0, + 'temperature': 12.0, + 'templow': 11.0, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00', + 'humidity': 75, + 'precipitation': 4.8, + 'pressure': 1011.0, + 'temperature': 14.0, + 'templow': 10.0, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00', + 'humidity': 69, + 'precipitation': 0.6, + 'pressure': 1015.0, + 'temperature': 18.0, + 'templow': 12.0, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 17.0, + 'templow': 12.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1015.0, + 'temperature': 21.0, + 'templow': 14.0, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00', + 'humidity': 64, + 'precipitation': 3.6, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00', + 'humidity': 61, + 'precipitation': 2.4, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }), + }) +# --- # name: test_forecast_services dict({ 'cloud_coverage': 100, diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 67aa18ea75d..f12aa92df3c 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -20,7 +20,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.components.weather.const import ( ATTR_WEATHER_CLOUD_COVERAGE, @@ -443,11 +444,19 @@ async def test_forecast_services_lack_of_data( assert forecast1 is None +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test forecast service.""" uri = APIURL_TEMPLATE.format( @@ -463,7 +472,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": ENTITY_ID, "type": "daily"}, blocking=True, return_response=True, diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index 72af2ab1637..0ee7f967176 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -1,4 +1,155 @@ # serializer version: 1 +# name: test_forecasts[config0-1-weather-forecast] + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-forecast].1 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-forecast].2 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'fog', + 'datetime': '2023-02-17T14:00:00+00:00', + 'is_daytime': True, + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-forecast].3 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 16.9, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecast].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'fog', + 'datetime': '2023-02-17T14:00:00+00:00', + 'is_daytime': True, + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecast].3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 16.9, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecasts] + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecasts].1 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecasts].2 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'fog', + 'datetime': '2023-02-17T14:00:00+00:00', + 'is_daytime': True, + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecasts].3 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 16.9, + }), + ]), + }), + }) +# --- # name: test_forecasts[config0-1-weather] dict({ 'forecast': list([ @@ -59,6 +210,138 @@ 'last_wind_speed': None, }) # --- +# name: test_trigger_weather_services[config0-1-template-forecast] + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-forecast].1 + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-forecast].2 + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'is_daytime': True, + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecast].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'is_daytime': True, + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecasts] + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecasts].1 + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecasts].2 + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'is_daytime': True, + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- # name: test_trigger_weather_services[config0-1-template] dict({ 'forecast': list([ diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 524f9c41aeb..36071c746da 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -18,7 +18,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, Forecast, ) from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN @@ -92,6 +93,13 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: assert state.attributes.get(v_attr) == value +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -114,7 +122,7 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: ], ) async def test_forecasts( - hass: HomeAssistant, start_ha, snapshot: SnapshotAssertion + hass: HomeAssistant, start_ha, snapshot: SnapshotAssertion, service: str ) -> None: """Test forecast service.""" for attr, _v_attr, value in [ @@ -161,7 +169,7 @@ async def test_forecasts( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "daily"}, blocking=True, return_response=True, @@ -169,7 +177,7 @@ async def test_forecasts( assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "hourly"}, blocking=True, return_response=True, @@ -177,7 +185,7 @@ async def test_forecasts( assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "twice_daily"}, blocking=True, return_response=True, @@ -204,7 +212,7 @@ async def test_forecasts( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "daily"}, blocking=True, return_response=True, @@ -212,6 +220,13 @@ async def test_forecasts( assert response == snapshot +@pytest.mark.parametrize( + ("service", "expected"), + [ + (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), + (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -236,6 +251,8 @@ async def test_forecast_invalid( hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, + service: str, + expected: dict[str, Any], ) -> None: """Test invalid forecasts.""" for attr, _v_attr, value in [ @@ -271,23 +288,30 @@ async def test_forecast_invalid( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "daily"}, blocking=True, return_response=True, ) - assert response == {"forecast": []} + assert response == expected response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "hourly"}, blocking=True, return_response=True, ) - assert response == {"forecast": []} + assert response == expected assert "Only valid keys in Forecast are allowed" in caplog.text +@pytest.mark.parametrize( + ("service", "expected"), + [ + (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), + (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -311,6 +335,8 @@ async def test_forecast_invalid_is_daytime_missing_in_twice_daily( hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, + service: str, + expected: dict[str, Any], ) -> None: """Test forecast service invalid when is_daytime missing in twice_daily forecast.""" for attr, _v_attr, value in [ @@ -340,15 +366,22 @@ async def test_forecast_invalid_is_daytime_missing_in_twice_daily( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "twice_daily"}, blocking=True, return_response=True, ) - assert response == {"forecast": []} + assert response == expected assert "`is_daytime` is missing in twice_daily forecast" in caplog.text +@pytest.mark.parametrize( + ("service", "expected"), + [ + (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), + (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -372,6 +405,8 @@ async def test_forecast_invalid_datetime_missing( hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, + service: str, + expected: dict[str, Any], ) -> None: """Test forecast service invalid when datetime missing.""" for attr, _v_attr, value in [ @@ -401,15 +436,22 @@ async def test_forecast_invalid_datetime_missing( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "twice_daily"}, blocking=True, return_response=True, ) - assert response == {"forecast": []} + assert response == expected assert "`datetime` is required in forecasts" in caplog.text +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -431,7 +473,7 @@ async def test_forecast_invalid_datetime_missing( ], ) async def test_forecast_format_error( - hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, service: str ) -> None: """Test forecast service invalid on incorrect format.""" for attr, _v_attr, value in [ @@ -467,7 +509,7 @@ async def test_forecast_format_error( await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "daily"}, blocking=True, return_response=True, @@ -475,7 +517,7 @@ async def test_forecast_format_error( assert "Forecasts is not a list, see Weather documentation" in caplog.text await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "hourly"}, blocking=True, return_response=True, @@ -638,6 +680,13 @@ async def test_trigger_action( assert state.context is context +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( "config", @@ -694,6 +743,7 @@ async def test_trigger_weather_services( start_ha, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test trigger weather entity with services.""" state = hass.states.get("weather.test") @@ -756,7 +806,7 @@ async def test_trigger_weather_services( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": state.entity_id, "type": "daily", @@ -768,7 +818,7 @@ async def test_trigger_weather_services( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": state.entity_id, "type": "hourly", @@ -780,7 +830,7 @@ async def test_trigger_weather_services( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": state.entity_id, "type": "twice_daily", diff --git a/tests/components/tomorrowio/snapshots/test_weather.ambr b/tests/components/tomorrowio/snapshots/test_weather.ambr index a938cb10e44..fe65925e4c7 100644 --- a/tests/components/tomorrowio/snapshots/test_weather.ambr +++ b/tests/components/tomorrowio/snapshots/test_weather.ambr @@ -1107,3 +1107,1127 @@ ]), }) # --- +# name: test_v4_forecast_service[forecast] + dict({ + 'weather.tomorrow_io_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]), + }), + }) +# --- +# name: test_v4_forecast_service[forecast].1 + dict({ + 'weather.tomorrow_io_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]), + }), + }) +# --- +# name: test_v4_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]), + }) +# --- +# name: test_v4_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]), + }) +# --- +# name: test_v4_forecast_service[get_forecasts] + dict({ + 'weather.tomorrow_io_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]), + }), + }) +# --- +# name: test_v4_forecast_service[get_forecasts].1 + dict({ + 'weather.tomorrow_io_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]), + }), + }) +# --- diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 863623ee524..e715fccea6b 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -46,7 +46,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, SOURCE_USER from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, CONF_NAME @@ -277,10 +278,18 @@ async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == "km/h" +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @freeze_time(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) async def test_v4_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" weather_state = await _setup(hass, API_V4_ENTRY_DATA) @@ -289,7 +298,7 @@ async def test_v4_forecast_service( for forecast_type in ("daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity_id, "type": forecast_type, @@ -297,10 +306,40 @@ async def test_v4_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot +async def test_legacy_v4_bad_forecast( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + tomorrowio_config_entry_update, + snapshot: SnapshotAssertion, +) -> None: + """Test bad forecast data.""" + freezer.move_to(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) + + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + entity_id = weather_state.entity_id + hourly_forecast = tomorrowio_config_entry_update.return_value["forecasts"]["hourly"] + hourly_forecast[0]["values"]["precipitationProbability"] = "blah" + + # Trigger data refetch + freezer.tick(timedelta(minutes=32) + timedelta(seconds=1)) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + LEGACY_SERVICE_GET_FORECAST, + { + "entity_id": entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"][0]["precipitation_probability"] is None + + async def test_v4_bad_forecast( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -321,7 +360,7 @@ async def test_v4_bad_forecast( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, { "entity_id": entity_id, "type": "hourly", @@ -329,7 +368,12 @@ async def test_v4_bad_forecast( blocking=True, return_response=True, ) - assert response["forecast"][0]["precipitation_probability"] is None + assert ( + response["weather.tomorrow_io_daily"]["forecast"][0][ + "precipitation_probability" + ] + is None + ) @pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) diff --git a/tests/components/weather/snapshots/test_init.ambr b/tests/components/weather/snapshots/test_init.ambr index 03a2d46c80f..1aa78f6bf35 100644 --- a/tests/components/weather/snapshots/test_init.ambr +++ b/tests/components/weather/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_get_forecast[daily-1] +# name: test_get_forecast[daily-1-get_forecast] dict({ 'forecast': list([ dict({ @@ -12,7 +12,22 @@ ]), }) # --- -# name: test_get_forecast[hourly-2] +# name: test_get_forecast[daily-1-get_forecasts] + dict({ + 'weather.testing': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- +# name: test_get_forecast[hourly-2-get_forecast] dict({ 'forecast': list([ dict({ @@ -25,7 +40,22 @@ ]), }) # --- -# name: test_get_forecast[twice_daily-4] +# name: test_get_forecast[hourly-2-get_forecasts] + dict({ + 'weather.testing': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- +# name: test_get_forecast[twice_daily-4-get_forecast] dict({ 'forecast': list([ dict({ @@ -39,3 +69,19 @@ ]), }) # --- +# name: test_get_forecast[twice_daily-4-get_forecasts] + dict({ + 'weather.testing': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'is_daytime': True, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index f62bed295da..3890d6a28d1 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -32,8 +32,9 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN, + LEGACY_SERVICE_GET_FORECAST, ROUNDING_PRECISION, - SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, Forecast, WeatherEntity, WeatherEntityFeature, @@ -959,6 +960,13 @@ async def test_forecast_twice_daily_missing_is_daytime( assert msg["type"] == "result" +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize( ("forecast_type", "supported_features"), [ @@ -976,6 +984,7 @@ async def test_get_forecast( forecast_type: str, supported_features: int, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test get forecast service.""" @@ -1006,7 +1015,7 @@ async def test_get_forecast( response = await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity0.entity_id, "type": forecast_type, @@ -1017,9 +1026,30 @@ async def test_get_forecast( assert response == snapshot +@pytest.mark.parametrize( + ("service", "expected"), + [ + ( + SERVICE_GET_FORECASTS, + { + "weather.testing": { + "forecast": [], + } + }, + ), + ( + LEGACY_SERVICE_GET_FORECAST, + { + "forecast": [], + }, + ), + ], +) async def test_get_forecast_no_forecast( hass: HomeAssistant, config_flow_fixture: None, + service: str, + expected: dict[str, list | dict[str, list]], ) -> None: """Test get forecast service.""" @@ -1040,7 +1070,7 @@ async def test_get_forecast_no_forecast( response = await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity0.entity_id, "type": "daily", @@ -1048,11 +1078,16 @@ async def test_get_forecast_no_forecast( blocking=True, return_response=True, ) - assert response == { - "forecast": [], - } + assert response == expected +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize( ("supported_features", "forecast_types"), [ @@ -1066,6 +1101,7 @@ async def test_get_forecast_unsupported( config_flow_fixture: None, forecast_types: list[str], supported_features: int, + service: str, ) -> None: """Test get forecast service.""" @@ -1095,7 +1131,7 @@ async def test_get_forecast_unsupported( with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": weather_entity.entity_id, "type": forecast_type, @@ -1255,3 +1291,52 @@ async def test_issue_forecast_deprecated_no_logging( "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" not in caplog.text ) + + +async def test_issue_deprecated_service_weather_get_forecast( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the issue is raised on deprecated service weather.get_forecast.""" + + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": WeatherEntityFeature.FORECAST_DAILY, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) + + _ = await hass.services.async_call( + DOMAIN, + LEGACY_SERVICE_GET_FORECAST, + { + "entity_id": entity0.entity_id, + "type": "daily", + }, + blocking=True, + return_response=True, + ) + + issue = issue_registry.async_get_issue( + "weather", "deprecated_service_weather_get_forecast" + ) + assert issue + assert issue.issue_domain == "test" + assert issue.issue_id == "deprecated_service_weather_get_forecast" + assert issue.translation_key == "deprecated_service_weather_get_forecast" + + assert ( + "Detected use of service 'weather.get_forecast'. " + "This is deprecated and will stop working in Home Assistant 2024.6. " + "Use 'weather.get_forecasts' instead which supports multiple entities" + ) in caplog.text diff --git a/tests/components/weatherkit/snapshots/test_weather.ambr b/tests/components/weatherkit/snapshots/test_weather.ambr index 63321b5a813..1fbe5389e98 100644 --- a/tests/components/weatherkit/snapshots/test_weather.ambr +++ b/tests/components/weatherkit/snapshots/test_weather.ambr @@ -95,6 +95,298 @@ ]), }) # --- +# name: test_daily_forecast[forecast] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }), + }) +# --- +# name: test_daily_forecast[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }) +# --- +# name: test_daily_forecast[get_forecasts] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }), + }) +# --- # name: test_hourly_forecast dict({ 'forecast': list([ @@ -4085,3 +4377,11977 @@ ]), }) # --- +# name: test_hourly_forecast[forecast] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }), + }) +# --- +# name: test_hourly_forecast[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }) +# --- +# name: test_hourly_forecast[get_forecasts] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }), + }) +# --- diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py index fabd3aab572..3b3a9a50d7f 100644 --- a/tests/components/weatherkit/test_weather.py +++ b/tests/components/weatherkit/test_weather.py @@ -1,5 +1,6 @@ """Weather entity tests for the WeatherKit integration.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.weather import ( @@ -15,7 +16,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.components.weather.const import WeatherEntityFeature from homeassistant.components.weatherkit.const import ATTRIBUTION @@ -77,15 +79,22 @@ async def test_hourly_forecast_missing(hass: HomeAssistant) -> None: ) == 0 +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_hourly_forecast( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, snapshot: SnapshotAssertion, service: str ) -> None: """Test states of the hourly forecast.""" await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.home", "type": "hourly", @@ -93,17 +102,25 @@ async def test_hourly_forecast( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot -async def test_daily_forecast(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) +async def test_daily_forecast( + hass: HomeAssistant, snapshot: SnapshotAssertion, service: str +) -> None: """Test states of the daily forecast.""" await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.home", "type": "daily", @@ -111,5 +128,4 @@ async def test_daily_forecast(hass: HomeAssistant, snapshot: SnapshotAssertion) blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot From a1678ebd231405843381b61e743b5055908c2d2b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 19 Nov 2023 20:46:03 +0100 Subject: [PATCH 592/982] Add Reolink day night switch threshold (#104219) --- homeassistant/components/reolink/number.py | 13 +++++++++++++ homeassistant/components/reolink/strings.json | 3 +++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 7e3f6483fb3..ef9b01a7a52 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -298,6 +298,19 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.auto_track_stop_time(ch), method=lambda api, ch, value: api.set_auto_tracking(ch, stop_time=int(value)), ), + ReolinkNumberEntityDescription( + key="day_night_switch_threshold", + translation_key="day_night_switch_threshold", + icon="mdi:theme-light-dark", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "dayNightThreshold"), + value=lambda api, ch: api.daynight_threshold(ch), + method=lambda api, ch, value: api.set_daynight_threshold(ch, int(value)), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 0a496d62522..e2d9ec95af5 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -215,6 +215,9 @@ }, "auto_track_stop_time": { "name": "Auto track stop time" + }, + "day_night_switch_threshold": { + "name": "Day night switch threshold" } }, "select": { From f8e3f1497cac5aa21b65342096d3d520bf1c3de9 Mon Sep 17 00:00:00 2001 From: Rene Nemec <50780524+ertechdesign@users.noreply.github.com> Date: Sun, 19 Nov 2023 22:49:40 +0000 Subject: [PATCH 593/982] Increase Tomato request timeout (#104203) * tomato integration timeout fixed * update tests in tomato integration --- homeassistant/components/tomato/device_tracker.py | 4 ++-- tests/components/tomato/test_device_tracker.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index da64157dad8..d71dd45bcfe 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -100,10 +100,10 @@ class TomatoDeviceScanner(DeviceScanner): try: if self.ssl: response = requests.Session().send( - self.req, timeout=3, verify=self.verify_ssl + self.req, timeout=60, verify=self.verify_ssl ) else: - response = requests.Session().send(self.req, timeout=3) + response = requests.Session().send(self.req, timeout=60) # Calling and parsing the Tomato api here. We only need the # wldev and dhcpd_lease values. diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index 7c187c7b4bb..11e73b5695c 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -157,7 +157,7 @@ def test_config_verify_ssl_but_no_ssl_enabled( assert "_http_id=1234567890" in result.req.body assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 - assert mock_session_send.mock_calls[0] == mock.call(result.req, timeout=3) + assert mock_session_send.mock_calls[0] == mock.call(result.req, timeout=60) @mock.patch("os.access", return_value=True) @@ -192,7 +192,7 @@ def test_config_valid_verify_ssl_path(hass: HomeAssistant, mock_session_send) -> assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 assert mock_session_send.mock_calls[0] == mock.call( - result.req, timeout=3, verify="/test/tomato.crt" + result.req, timeout=60, verify="/test/tomato.crt" ) @@ -223,7 +223,7 @@ def test_config_valid_verify_ssl_bool(hass: HomeAssistant, mock_session_send) -> assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 assert mock_session_send.mock_calls[0] == mock.call( - result.req, timeout=3, verify=False + result.req, timeout=60, verify=False ) From 6ef194f992845d0f7e7452623ce56f522045fbfd Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 19 Nov 2023 19:24:43 -0500 Subject: [PATCH 594/982] Add listeners for roborock (#103651) * Add listeners for roborock * add tests * decrease test complexity --- homeassistant/components/roborock/device.py | 11 +++++- homeassistant/components/roborock/select.py | 6 +++ homeassistant/components/roborock/sensor.py | 11 ++++++ homeassistant/components/roborock/vacuum.py | 7 ++++ tests/components/roborock/test_sensor.py | 44 +++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 5fca40a9fd8..71376dd600e 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -5,7 +5,7 @@ from typing import Any from roborock.api import AttributeCache, RoborockClient from roborock.cloud_api import RoborockMqttClient from roborock.command_cache import CacheableAttribute -from roborock.containers import Status +from roborock.containers import Consumable, Status from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand @@ -97,3 +97,12 @@ class RoborockCoordinatedEntity( res = await super().send(command, params) await self.coordinator.async_refresh() return res + + def _update_from_listener(self, value: Status | Consumable): + """Update the status or consumable data from a listener and then write the new entity state.""" + if isinstance(value, Status): + self.coordinator.roborock_device_info.props.status = value + else: + self.coordinator.roborock_device_info.props.consumable = value + self.coordinator.data = self.coordinator.roborock_device_info.props + self.async_write_ha_state() diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index f4968bf7db9..1a05f3ec9c1 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -3,6 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass from roborock.containers import Status +from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -37,6 +38,8 @@ class RoborockSelectDescription( ): """Class to describe an Roborock select entity.""" + protocol_listener: RoborockDataProtocol | None = None + SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ RoborockSelectDescription( @@ -49,6 +52,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ if data.water_box_mode is not None else None, parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)], + protocol_listener=RoborockDataProtocol.WATER_BOX_MODE, ), RoborockSelectDescription( key="mop_mode", @@ -105,6 +109,8 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): self.entity_description = entity_description super().__init__(unique_id, coordinator) self._attr_options = options + if (protocol := self.entity_description.protocol_listener) is not None: + self.api.add_listener(protocol, self._update_from_listener, self.api.cache) async def async_select_option(self, option: str) -> None: """Set the option.""" diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 090ab2f233c..775fc0cfb5f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -11,6 +11,7 @@ from roborock.containers import ( RoborockErrorCode, RoborockStateCode, ) +from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -48,6 +49,8 @@ class RoborockSensorDescription( ): """A class that describes Roborock sensors.""" + protocol_listener: RoborockDataProtocol | None = None + def _dock_error_value_fn(properties: DeviceProp) -> str | None: if ( @@ -67,6 +70,7 @@ SENSOR_DESCRIPTIONS = [ translation_key="main_brush_time_left", value_fn=lambda data: data.consumable.main_brush_time_left, entity_category=EntityCategory.DIAGNOSTIC, + protocol_listener=RoborockDataProtocol.MAIN_BRUSH_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -76,6 +80,7 @@ SENSOR_DESCRIPTIONS = [ translation_key="side_brush_time_left", value_fn=lambda data: data.consumable.side_brush_time_left, entity_category=EntityCategory.DIAGNOSTIC, + protocol_listener=RoborockDataProtocol.SIDE_BRUSH_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -85,6 +90,7 @@ SENSOR_DESCRIPTIONS = [ translation_key="filter_time_left", value_fn=lambda data: data.consumable.filter_time_left, entity_category=EntityCategory.DIAGNOSTIC, + protocol_listener=RoborockDataProtocol.FILTER_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -120,6 +126,7 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.status.state_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockStateCode.keys(), + protocol_listener=RoborockDataProtocol.STATE, ), RoborockSensorDescription( key="cleaning_area", @@ -145,6 +152,7 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.status.error_code_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockErrorCode.keys(), + protocol_listener=RoborockDataProtocol.ERROR_CODE, ), RoborockSensorDescription( key="battery", @@ -152,6 +160,7 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + protocol_listener=RoborockDataProtocol.BATTERY, ), RoborockSensorDescription( key="last_clean_start", @@ -238,6 +247,8 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): """Initialize the entity.""" super().__init__(unique_id, coordinator) self.entity_description = description + if (protocol := self.entity_description.protocol_listener) is not None: + self.api.add_listener(protocol, self._update_from_listener, self.api.cache) @property def native_value(self) -> StateType | datetime.datetime: diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 0edd8e3ec5a..c8b43e74efd 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -2,6 +2,7 @@ from typing import Any from roborock.code_mappings import RoborockStateCode +from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( @@ -94,6 +95,12 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): StateVacuumEntity.__init__(self) RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) self._attr_fan_speed_list = self._device_status.fan_power_options + self.api.add_listener( + RoborockDataProtocol.FAN_POWER, self._update_from_listener, self.api.cache + ) + self.api.add_listener( + RoborockDataProtocol.STATE, self._update_from_listener, self.api.cache + ) @property def state(self) -> str | None: diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 35fcc9478cd..4966c8fa3be 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -1,14 +1,20 @@ """Test Roborock Sensors.""" +from unittest.mock import patch +from roborock import DeviceData, HomeDataDevice +from roborock.cloud_api import RoborockMqttClient from roborock.const import ( FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME, ) +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from homeassistant.core import HomeAssistant +from .mock_data import CONSUMABLE, STATUS, USER_DATA + from tests.common import MockConfigEntry @@ -47,3 +53,41 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state == "2023-01-01T03:43:58+00:00" ) + + +async def test_listener_update( + hass: HomeAssistant, setup_entry: MockConfigEntry +) -> None: + """Test that when we receive a mqtt topic, we successfully update the entity.""" + assert hass.states.get("sensor.roborock_s7_maxv_status").state == "charging" + # Listeners are global based on uuid - so this is okay + client = RoborockMqttClient( + USER_DATA, DeviceData(device=HomeDataDevice("abc123", "", "", "", ""), model="") + ) + # Test Status + with patch("roborock.api.AttributeCache.value", STATUS.as_dict()): + # Symbolizes a mqtt message coming in + client.on_message_received( + [ + RoborockMessage( + protocol=RoborockMessageProtocol.GENERAL_REQUEST, + payload=b'{"t": 1699464794, "dps": {"121": 5}}', + ) + ] + ) + # Test consumable + assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str( + FILTER_REPLACE_TIME - 74382 + ) + with patch("roborock.api.AttributeCache.value", CONSUMABLE.as_dict()): + client.on_message_received( + [ + RoborockMessage( + protocol=RoborockMessageProtocol.GENERAL_REQUEST, + payload=b'{"t": 1699464794, "dps": {"127": 743}}', + ) + ] + ) + assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str( + FILTER_REPLACE_TIME - 743 + ) From cc31d772055e1c8be1e725a5cb366b11ed481412 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 21:27:24 -0600 Subject: [PATCH 595/982] Use ulid_now instead of ulid_at_timestamp if no timestamp is passed (#104226) * Use ulid_now instead of ulid_at_timestamp if no timestamp is passed ulid_now is slightly faster than ulid_at_timestamp * tweak usage --- .../components/assist_pipeline/pipeline.py | 8 +++---- .../__init__.py | 2 +- .../openai_conversation/__init__.py | 2 +- .../components/thread/dataset_store.py | 2 +- homeassistant/components/voip/voip.py | 4 ++-- homeassistant/core.py | 4 ++-- homeassistant/util/ulid.py | 21 ++++++++++++++----- 7 files changed, 27 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 71e93371257..fa7d2115769 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -320,7 +320,7 @@ class Pipeline: wake_word_entity: str | None wake_word_id: str | None - id: str = field(default_factory=ulid_util.ulid) + id: str = field(default_factory=ulid_util.ulid_now) @classmethod def from_json(cls, data: dict[str, Any]) -> Pipeline: @@ -482,7 +482,7 @@ class PipelineRun: wake_word_settings: WakeWordSettings | None = None audio_settings: AudioSettings = field(default_factory=AudioSettings) - id: str = field(default_factory=ulid_util.ulid) + id: str = field(default_factory=ulid_util.ulid_now) stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) tts_engine: str = field(init=False, repr=False) tts_options: dict | None = field(init=False, default=None) @@ -1476,7 +1476,7 @@ class PipelineStorageCollection( @callback def _get_suggested_id(self, info: dict) -> str: """Suggest an ID based on the config.""" - return ulid_util.ulid() + return ulid_util.ulid_now() async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline: """Return a new updated item.""" @@ -1664,7 +1664,7 @@ class DeviceAudioQueue: queue: asyncio.Queue[bytes | None] """Queue of audio chunks (None = stop signal)""" - id: str = field(default_factory=ulid_util.ulid) + id: str = field(default_factory=ulid_util.ulid_now) """Unique id to ensure the correct audio queue is cleaned up in websocket API.""" overflow: bool = False diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 1154c7132d2..c507e0c046d 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -88,7 +88,7 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): conversation_id = user_input.conversation_id messages = self.history[conversation_id] else: - conversation_id = ulid.ulid() + conversation_id = ulid.ulid_now() messages = [] try: diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 9f4c30d91ba..0279580e56b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -141,7 +141,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): conversation_id = user_input.conversation_id messages = self.history[conversation_id] else: - conversation_id = ulid.ulid() + conversation_id = ulid.ulid_now() try: prompt = self._async_generate_prompt(raw_prompt) except TemplateError as err: diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index f814fbffbd0..9c5d79cc0e0 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -38,7 +38,7 @@ class DatasetEntry: tlv: str created: datetime = dataclasses.field(default_factory=dt_util.utcnow) - id: str = dataclasses.field(default_factory=ulid_util.ulid) + id: str = dataclasses.field(default_factory=ulid_util.ulid_now) @property def channel(self) -> int | None: diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 6ea97268684..14e1211639e 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -37,7 +37,7 @@ from homeassistant.components.assist_pipeline.vad import ( ) from homeassistant.const import __version__ from homeassistant.core import Context, HomeAssistant -from homeassistant.util.ulid import ulid +from homeassistant.util.ulid import ulid_now from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH @@ -219,7 +219,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): ) -> None: """Forward audio to pipeline STT and handle TTS.""" if self._session_id is None: - self._session_id = ulid() + self._session_id = ulid_now() # Play listening tone at the start of each cycle if self.listening_tone_enabled: diff --git a/homeassistant/core.py b/homeassistant/core.py index d174786d968..a552b53c9c4 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -91,7 +91,7 @@ from .util.async_ import ( from .util.json import JsonObjectType from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager -from .util.ulid import ulid, ulid_at_time +from .util.ulid import ulid_at_time, ulid_now from .util.unit_system import ( _CONF_UNIT_SYSTEM_IMPERIAL, _CONF_UNIT_SYSTEM_US_CUSTOMARY, @@ -930,7 +930,7 @@ class Context: id: str | None = None, # pylint: disable=redefined-builtin ) -> None: """Init the context.""" - self.id = id or ulid() + self.id = id or ulid_now() self.user_id = user_id self.parent_id = parent_id self.origin_event: Event | None = None diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index 643286cedb9..818b8015549 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -1,11 +1,22 @@ """Helpers to generate ulids.""" from __future__ import annotations -import time +from ulid_transform import ( + bytes_to_ulid, + ulid_at_time, + ulid_hex, + ulid_now, + ulid_to_bytes, +) -from ulid_transform import bytes_to_ulid, ulid_at_time, ulid_hex, ulid_to_bytes - -__all__ = ["ulid", "ulid_hex", "ulid_at_time", "ulid_to_bytes", "bytes_to_ulid"] +__all__ = [ + "ulid", + "ulid_hex", + "ulid_at_time", + "ulid_to_bytes", + "bytes_to_ulid", + "ulid_now", +] def ulid(timestamp: float | None = None) -> str: @@ -25,4 +36,4 @@ def ulid(timestamp: float | None = None) -> str: import ulid ulid.parse(ulid_util.ulid()) """ - return ulid_at_time(timestamp or time.time()) + return ulid_now() if timestamp is None else ulid_at_time(timestamp) From 7160e956a67ca6ff5474f88c8578d64c78b0a84c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 21:27:44 -0600 Subject: [PATCH 596/982] Bump aioesphomeapi to 18.5.4 (#104187) This is mostly to clean up duplicate code in the lib, but it will also make connecting just a tiny bit faster We have reached over ~83% coverage in the library now --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index bc2b87f6397..e3ac4ac5600 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.5.3", + "aioesphomeapi==18.5.4", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5dd31ad2189..3f28c8b18f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.3 +aioesphomeapi==18.5.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa4e1f79fec..d09c4dfa292 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.3 +aioesphomeapi==18.5.4 # homeassistant.components.flo aioflo==2021.11.0 From e6226b092442c9a0d30ce0b166054b495131394b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 20 Nov 2023 07:07:29 +0000 Subject: [PATCH 597/982] Add height sensor to Idasen Desk integration (#103324) --- .../components/idasen_desk/__init__.py | 2 +- .../components/idasen_desk/manifest.json | 2 +- .../components/idasen_desk/sensor.py | 100 ++++++++++++++++++ .../components/idasen_desk/strings.json | 7 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/idasen_desk/conftest.py | 1 + tests/components/idasen_desk/test_sensors.py | 27 +++++ 8 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/idasen_desk/sensor.py create mode 100644 tests/components/idasen_desk/test_sensors.py diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 564406d423e..5e112aa39f7 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 9681b2136e1..0a96a976bb3 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["idasen-ha==2.3"] + "requirements": ["idasen-ha==2.4"] } diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py new file mode 100644 index 00000000000..cb9668dc8f7 --- /dev/null +++ b/homeassistant/components/idasen_desk/sensor.py @@ -0,0 +1,100 @@ +"""Representation of Idasen Desk sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DeskData, IdasenDeskCoordinator +from .const import DOMAIN + + +@dataclass +class IdasenDeskSensorDescriptionMixin: + """Required values for IdasenDesk sensors.""" + + value_fn: Callable[[IdasenDeskCoordinator], float | None] + + +@dataclass +class IdasenDeskSensorDescription( + SensorEntityDescription, + IdasenDeskSensorDescriptionMixin, +): + """Class describing IdasenDesk sensor entities.""" + + +SENSORS = ( + IdasenDeskSensorDescription( + key="height", + translation_key="height", + icon="mdi:arrow-up-down", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + suggested_display_precision=3, + value_fn=lambda coordinator: coordinator.desk.height, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Idasen Desk sensors.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + IdasenDeskSensor( + data.address, data.device_info, data.coordinator, sensor_description + ) + for sensor_description in SENSORS + ) + + +class IdasenDeskSensor(CoordinatorEntity, SensorEntity): + """IdasenDesk sensor.""" + + entity_description: IdasenDeskSensorDescription + _attr_has_entity_name = True + + def __init__( + self, + address: str, + device_info: DeviceInfo, + coordinator: IdasenDeskCoordinator, + description: IdasenDeskSensorDescription, + ) -> None: + """Initialize the IdasenDesk sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + + self._attr_unique_id = f"{description.key}-{address}" + self._attr_device_info = device_info + self._address = address + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self, *args: Any) -> None: + """Handle data update.""" + self._attr_native_value = self.entity_description.value_fn(self.coordinator) + super()._handle_coordinator_update() diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index 6b9bf80edfc..446ef93e542 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -19,5 +19,12 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "No unconfigured devices found. Make sure that the desk is in Bluetooth pairing mode. Enter pairing mode by pressing the small button with the Bluetooth logo on the controller for about 3 seconds, until it starts blinking." } + }, + "entity": { + "sensor": { + "height": { + "name": "Height" + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 3f28c8b18f1..780d2264f9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1061,7 +1061,7 @@ ical==6.1.0 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.3 +idasen-ha==2.4 # homeassistant.components.network ifaddr==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d09c4dfa292..9ac9060abab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -836,7 +836,7 @@ ical==6.1.0 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.3 +idasen-ha==2.4 # homeassistant.components.network ifaddr==0.2.0 diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py index d6c2ba5ad6b..8159039aff4 100644 --- a/tests/components/idasen_desk/conftest.py +++ b/tests/components/idasen_desk/conftest.py @@ -55,6 +55,7 @@ def mock_desk_api(): mock_desk.move_up = AsyncMock(side_effect=mock_move_up) mock_desk.move_down = AsyncMock(side_effect=mock_move_down) mock_desk.stop = AsyncMock() + mock_desk.height = 1 mock_desk.height_percent = 60 mock_desk.is_moving = False mock_desk.address = "AA:BB:CC:DD:EE:FF" diff --git a/tests/components/idasen_desk/test_sensors.py b/tests/components/idasen_desk/test_sensors.py new file mode 100644 index 00000000000..23d7ac2447b --- /dev/null +++ b/tests/components/idasen_desk/test_sensors.py @@ -0,0 +1,27 @@ +"""Test the IKEA Idasen Desk sensors.""" +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_height_sensor( + hass: HomeAssistant, + mock_desk_api: MagicMock, + entity_registry_enabled_by_default: None, +) -> None: + """Test height sensor.""" + await init_integration(hass) + + entity_id = "sensor.test_height" + state = hass.states.get(entity_id) + assert state + assert state.state == "1" + + mock_desk_api.height = 1.2 + mock_desk_api.trigger_update_callback(None) + + state = hass.states.get(entity_id) + assert state + assert state.state == "1.2" From ae2099b2ebc3166fff869ae37bc39a709ba50c71 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 20 Nov 2023 09:22:26 +0100 Subject: [PATCH 598/982] Reolink: fix typo in UI strings (#104236) --- homeassistant/components/reolink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index e2d9ec95af5..4170626b547 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -237,7 +237,7 @@ "state": { "auto": "Auto", "color": "Color", - "blackwhite": "Black&White" + "blackwhite": "Black & white" } }, "ptz_preset": { From a9384d6d4fbb938df35c5516be6d6365646beb2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Nov 2023 03:08:44 -0600 Subject: [PATCH 599/982] Fix ESPHome BLE client raising confusing error when not connected (#104146) --- .../components/esphome/bluetooth/client.py | 134 ++++++------------ .../esphome/bluetooth/test_client.py | 62 ++++++++ 2 files changed, 107 insertions(+), 89 deletions(-) create mode 100644 tests/components/esphome/bluetooth/test_client.py diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 970e866b27b..6cf1d6b5381 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -75,15 +75,13 @@ def verify_connected(func: _WrapFuncType) -> _WrapFuncType: self: ESPHomeClient, *args: Any, **kwargs: Any ) -> Any: # pylint: disable=protected-access + if not self._is_connected: + raise BleakError(f"{self._description} is not connected") loop = self._loop disconnected_futures = self._disconnected_futures disconnected_future = loop.create_future() disconnected_futures.add(disconnected_future) - ble_device = self._ble_device - disconnect_message = ( - f"{self._source_name }: {ble_device.name} - {ble_device.address}: " - "Disconnected during operation" - ) + disconnect_message = f"{self._description}: Disconnected during operation" try: async with interrupt(disconnected_future, BleakError, disconnect_message): return await func(self, *args, **kwargs) @@ -115,10 +113,8 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: if ex.error.error == -1: # pylint: disable=protected-access _LOGGER.debug( - "%s: %s - %s: BLE device disconnected during %s operation", - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: BLE device disconnected during %s operation", + self._description, func.__name__, ) self._async_ble_device_disconnected() @@ -159,10 +155,11 @@ class ESPHomeClient(BaseBleakClient): assert isinstance(address_or_ble_device, BLEDevice) super().__init__(address_or_ble_device, *args, **kwargs) self._loop = asyncio.get_running_loop() - self._ble_device = address_or_ble_device - self._address_as_int = mac_to_int(self._ble_device.address) - assert self._ble_device.details is not None - self._source = self._ble_device.details["source"] + ble_device = address_or_ble_device + self._ble_device = ble_device + self._address_as_int = mac_to_int(ble_device.address) + assert ble_device.details is not None + self._source = ble_device.details["source"] self._cache = client_data.cache self._bluetooth_device = client_data.bluetooth_device self._client = client_data.client @@ -177,8 +174,11 @@ class ESPHomeClient(BaseBleakClient): self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( client_data.api_version ) - self._address_type = address_or_ble_device.details["address_type"] + self._address_type = ble_device.details["address_type"] self._source_name = f"{client_data.title} [{self._source}]" + self._description = ( + f"{self._source_name}: {ble_device.name} - {ble_device.address}" + ) scanner = client_data.scanner assert scanner is not None self._scanner = scanner @@ -196,12 +196,10 @@ class ESPHomeClient(BaseBleakClient): except (AssertionError, ValueError) as ex: _LOGGER.debug( ( - "%s: %s - %s: Failed to unsubscribe from connection state (likely" + "%s: Failed to unsubscribe from connection state (likely" " connection dropped): %s" ), - self._source_name, - self._ble_device.name, - self._ble_device.address, + self._description, ex, ) self._cancel_connection_state = None @@ -224,22 +222,12 @@ class ESPHomeClient(BaseBleakClient): was_connected = self._is_connected self._async_disconnected_cleanup() if was_connected: - _LOGGER.debug( - "%s: %s - %s: BLE device disconnected", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: BLE device disconnected", self._description) self._async_call_bleak_disconnected_callback() def _async_esp_disconnected(self) -> None: """Handle the esp32 client disconnecting from us.""" - _LOGGER.debug( - "%s: %s - %s: ESP device disconnected", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: ESP device disconnected", self._description) self._disconnect_callbacks.remove(self._async_esp_disconnected) self._async_ble_device_disconnected() @@ -258,10 +246,8 @@ class ESPHomeClient(BaseBleakClient): ) -> None: """Handle a connect or disconnect.""" _LOGGER.debug( - "%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s", - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: Connection state changed to connected=%s mtu=%s error=%s", + self._description, connected, mtu, error, @@ -300,10 +286,8 @@ class ESPHomeClient(BaseBleakClient): return _LOGGER.debug( - "%s: %s - %s: connected, registering for disconnected callbacks", - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: connected, registering for disconnected callbacks", + self._description, ) self._disconnect_callbacks.append(self._async_esp_disconnected) connected_future.set_result(connected) @@ -403,10 +387,8 @@ class ESPHomeClient(BaseBleakClient): if bluetooth_device.ble_connections_free: return _LOGGER.debug( - "%s: %s - %s: Out of connection slots, waiting for a free one", - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: Out of connection slots, waiting for a free one", + self._description, ) async with asyncio.timeout(timeout): await bluetooth_device.wait_for_ble_connections_free() @@ -434,7 +416,7 @@ class ESPHomeClient(BaseBleakClient): if response.paired: return True _LOGGER.error( - "Pairing with %s failed due to error: %s", self.address, response.error + "%s: Pairing failed due to error: %s", self._description, response.error ) return False @@ -451,7 +433,7 @@ class ESPHomeClient(BaseBleakClient): if response.success: return True _LOGGER.error( - "Unpairing with %s failed due to error: %s", self.address, response.error + "%s: Unpairing failed due to error: %s", self._description, response.error ) return False @@ -486,30 +468,14 @@ class ESPHomeClient(BaseBleakClient): self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING or dangerous_use_bleak_cache ) and (cached_services := cache.get_gatt_services_cache(address_as_int)): - _LOGGER.debug( - "%s: %s - %s: Cached services hit", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: Cached services hit", self._description) self.services = cached_services return self.services - _LOGGER.debug( - "%s: %s - %s: Cached services miss", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: Cached services miss", self._description) esphome_services = await self._client.bluetooth_gatt_get_services( address_as_int ) - _LOGGER.debug( - "%s: %s - %s: Got services: %s", - self._source_name, - self._ble_device.name, - self._ble_device.address, - esphome_services, - ) + _LOGGER.debug("%s: Got services: %s", self._description, esphome_services) max_write_without_response = self.mtu_size - GATT_HEADER_SIZE services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] for service in esphome_services.services: @@ -538,12 +504,7 @@ class ESPHomeClient(BaseBleakClient): raise BleakError("Failed to get services from remote esp") self.services = services - _LOGGER.debug( - "%s: %s - %s: Cached services saved", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: Cached services saved", self._description) cache.set_gatt_services_cache(address_as_int, services) return services @@ -552,13 +513,15 @@ class ESPHomeClient(BaseBleakClient): ) -> BleakGATTCharacteristic: """Resolve a characteristic specifier to a BleakGATTCharacteristic object.""" if (services := self.services) is None: - raise BleakError("Services have not been resolved") + raise BleakError(f"{self._description}: Services have not been resolved") if not isinstance(char_specifier, BleakGATTCharacteristic): characteristic = services.get_characteristic(char_specifier) else: characteristic = char_specifier if not characteristic: - raise BleakError(f"Characteristic {char_specifier} was not found!") + raise BleakError( + f"{self._description}: Characteristic {char_specifier} was not found!" + ) return characteristic @verify_connected @@ -579,8 +542,8 @@ class ESPHomeClient(BaseBleakClient): if response.success: return True _LOGGER.error( - "Clear cache failed with %s failed due to error: %s", - self.address, + "%s: Clear cache failed due to error: %s", + self._description, response.error, ) return False @@ -692,7 +655,7 @@ class ESPHomeClient(BaseBleakClient): ble_handle = characteristic.handle if ble_handle in self._notify_cancels: raise BleakError( - "Notifications are already enabled on " + f"{self._description}: Notifications are already enabled on " f"service:{characteristic.service_uuid} " f"characteristic:{characteristic.uuid} " f"handle:{ble_handle}" @@ -702,8 +665,8 @@ class ESPHomeClient(BaseBleakClient): and "indicate" not in characteristic.properties ): raise BleakError( - f"Characteristic {characteristic.uuid} does not have notify or indicate" - " property set." + f"{self._description}: Characteristic {characteristic.uuid} " + "does not have notify or indicate property set." ) self._notify_cancels[ @@ -725,18 +688,13 @@ class ESPHomeClient(BaseBleakClient): cccd_descriptor = characteristic.get_descriptor(CCCD_UUID) if not cccd_descriptor: raise BleakError( - f"Characteristic {characteristic.uuid} does not have a " - "characteristic client config descriptor." + f"{self._description}: Characteristic {characteristic.uuid} " + "does not have a characteristic client config descriptor." ) _LOGGER.debug( - ( - "%s: %s - %s: Writing to CCD descriptor %s for notifications with" - " properties=%s" - ), - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: Writing to CCD descriptor %s for notifications with properties=%s", + self._description, cccd_descriptor.handle, characteristic.properties, ) @@ -774,12 +732,10 @@ class ESPHomeClient(BaseBleakClient): if self._cancel_connection_state: _LOGGER.warning( ( - "%s: %s - %s: ESPHomeClient bleak client was not properly" + "%s: ESPHomeClient bleak client was not properly" " disconnected before destruction" ), - self._source_name, - self._ble_device.name, - self._ble_device.address, + self._description, ) if not self._loop.is_closed(): self._loop.call_soon_threadsafe(self._async_disconnected_cleanup) diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py new file mode 100644 index 00000000000..7ed1403041d --- /dev/null +++ b/tests/components/esphome/bluetooth/test_client.py @@ -0,0 +1,62 @@ +"""Tests for ESPHomeClient.""" +from __future__ import annotations + +from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceInfo +from bleak.exc import BleakError +import pytest + +from homeassistant.components.bluetooth import HaBluetoothConnector +from homeassistant.components.esphome.bluetooth.cache import ESPHomeBluetoothCache +from homeassistant.components.esphome.bluetooth.client import ( + ESPHomeClient, + ESPHomeClientData, +) +from homeassistant.components.esphome.bluetooth.device import ESPHomeBluetoothDevice +from homeassistant.components.esphome.bluetooth.scanner import ESPHomeScanner +from homeassistant.core import HomeAssistant + +from tests.components.bluetooth import generate_ble_device + +ESP_MAC_ADDRESS = "AA:BB:CC:DD:EE:FF" +ESP_NAME = "proxy" + + +@pytest.fixture(name="client_data") +async def client_data_fixture( + hass: HomeAssistant, mock_client: APIClient +) -> ESPHomeClientData: + """Return a client data fixture.""" + connector = HaBluetoothConnector(ESPHomeClientData, ESP_MAC_ADDRESS, lambda: True) + return ESPHomeClientData( + bluetooth_device=ESPHomeBluetoothDevice(ESP_NAME, ESP_MAC_ADDRESS), + cache=ESPHomeBluetoothCache(), + client=mock_client, + device_info=DeviceInfo( + mac_address=ESP_MAC_ADDRESS, + name=ESP_NAME, + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + & BluetoothProxyFeature.ACTIVE_CONNECTIONS + & BluetoothProxyFeature.REMOTE_CACHING + & BluetoothProxyFeature.PAIRING + & BluetoothProxyFeature.CACHE_CLEARING + & BluetoothProxyFeature.RAW_ADVERTISEMENTS, + ), + api_version=APIVersion(1, 9), + title=ESP_NAME, + scanner=ESPHomeScanner( + hass, ESP_MAC_ADDRESS, ESP_NAME, lambda info: None, connector, True + ), + ) + + +async def test_client_usage_while_not_connected(client_data: ESPHomeClientData) -> None: + """Test client usage while not connected.""" + ble_device = generate_ble_device( + "CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1} + ) + + client = ESPHomeClient(ble_device, client_data=client_data) + with pytest.raises( + BleakError, match=f"{ESP_NAME}.*{ESP_MAC_ADDRESS}.*not connected" + ): + await client.write_gatt_char("test", b"test") is False From 88698d8dfe5cb6556d5280066886a694723d9a61 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Nov 2023 11:34:14 +0100 Subject: [PATCH 600/982] Fix docstring in yaml util (#104240) --- homeassistant/util/yaml/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 6c2cfa1f953..e1cfc81019c 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -238,7 +238,7 @@ def _add_reference( # type: ignore[no-untyped-def] def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: - """Load another YAML file and embeds it using the !include tag. + """Load another YAML file and embed it using the !include tag. Example: device_tracker: !include device_tracker.yaml From d90605f9bc1aa6801375abb2e570bd3b1d206efc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Nov 2023 12:34:58 +0100 Subject: [PATCH 601/982] Bump protobuf to 4.25.1 (#104231) changelog: https://github.com/protocolbuffers/protobuf/releases/tag/v25.1 --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ed695563e46..4ebc43dc01c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -154,7 +154,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.25.0 +protobuf==4.25.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2c442ed9796..404d257c414 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -150,7 +150,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.25.0 +protobuf==4.25.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From 67e25dc0bfa28afd18214f587de57c9b347fb558 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Nov 2023 12:55:16 +0100 Subject: [PATCH 602/982] Quote domain name in setup logs (#104239) * Quote domain name in setup logs * Update tests --- homeassistant/setup.py | 14 ++++++++------ tests/components/knx/test_button.py | 2 +- tests/test_setup.py | 6 +++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index bf405d5deda..9b705b4735e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -157,7 +157,7 @@ async def _async_process_dependencies( if failed: _LOGGER.error( - "Unable to set up dependencies of %s. Setup failed for dependencies: %s", + "Unable to set up dependencies of '%s'. Setup failed for dependencies: %s", integration.domain, ", ".join(failed), ) @@ -183,7 +183,7 @@ async def _async_setup_component( custom = "" if integration.is_built_in else "custom integration " link = integration.documentation _LOGGER.error( - "Setup failed for %s%s: %s", custom, domain, msg, exc_info=exc_info + "Setup failed for %s'%s': %s", custom, domain, msg, exc_info=exc_info ) async_notify_setup_error(hass, domain, link) @@ -234,8 +234,8 @@ async def _async_setup_component( ): _LOGGER.error( ( - "The %s integration does not support YAML setup, please remove it from " - "your configuration" + "The '%s' integration does not support YAML setup, please remove it " + "from your configuration" ), domain, ) @@ -289,7 +289,7 @@ async def _async_setup_component( except asyncio.TimeoutError: _LOGGER.error( ( - "Setup of %s is taking longer than %s seconds." + "Setup of '%s' is taking longer than %s seconds." " Startup will proceed without waiting any longer" ), domain, @@ -356,7 +356,9 @@ async def async_prepare_setup_platform( def log_error(msg: str) -> None: """Log helper.""" - _LOGGER.error("Unable to prepare setup for platform %s: %s", platform_path, msg) + _LOGGER.error( + "Unable to prepare setup for platform '%s': %s", platform_path, msg + ) async_notify_setup_error(hass, platform_path) try: diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 08afabbbdf8..a905e66fe5d 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -133,6 +133,6 @@ async def test_button_invalid( assert f"Invalid config for 'knx': {error_msg}" in record.message record = caplog.records[1] assert record.levelname == "ERROR" - assert "Setup failed for knx: Invalid config." in record.message + assert "Setup failed for 'knx': Invalid config." in record.message assert hass.states.get("button.test") is None assert hass.data.get(DOMAIN) is None diff --git a/tests/test_setup.py b/tests/test_setup.py index 8b3b79ac48c..66a62511fcb 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -523,7 +523,7 @@ async def test_platform_error_slow_setup( result = await setup.async_setup_component(hass, "test_component1", {}) assert len(called) == 1 assert not result - assert "test_component1 is taking longer than 0.1 seconds" in caplog.text + assert "'test_component1' is taking longer than 0.1 seconds" in caplog.text async def test_when_setup_already_loaded(hass: HomeAssistant) -> None: @@ -653,7 +653,7 @@ async def test_integration_logs_is_custom( ): result = await setup.async_setup_component(hass, "test_component1", {}) assert not result - assert "Setup failed for custom integration test_component1: Boom" in caplog.text + assert "Setup failed for custom integration 'test_component1': Boom" in caplog.text async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: @@ -735,7 +735,7 @@ async def test_setup_config_entry_from_yaml( ) -> None: """Test attempting to setup an integration which only supports config_entries.""" expected_warning = ( - "The test_integration_only_entry integration does not support YAML setup, " + "The 'test_integration_only_entry' integration does not support YAML setup, " "please remove it from your configuration" ) From afea9f773924f132a753802ef1364fbce4edfd3e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Nov 2023 12:55:27 +0100 Subject: [PATCH 603/982] Don't mutate config in the check_config helper (#104241) --- homeassistant/helpers/check_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index ea5e7218f1f..23707949dcd 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -232,10 +232,10 @@ async def async_check_ha_config_file( # noqa: C901 config_schema = getattr(component, "CONFIG_SCHEMA", None) if config_schema is not None: try: - config = config_schema(config) + validated_config = config_schema(config) # Don't fail if the validator removed the domain from the config - if domain in config: - result[domain] = config[domain] + if domain in validated_config: + result[domain] = validated_config[domain] except vol.Invalid as ex: _comp_error(ex, domain, config, config[domain]) continue From 2def7d2e716035af232bf392f60e649f2c92b6c1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Nov 2023 12:58:55 +0100 Subject: [PATCH 604/982] Catch ClientOSError in renault integration (#104248) --- homeassistant/components/renault/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index f69451290bc..6b5679088a0 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {}) try: await renault_hub.async_initialise(config_entry) - except aiohttp.ClientResponseError as exc: + except aiohttp.ClientError as exc: raise ConfigEntryNotReady() from exc hass.data[DOMAIN][config_entry.entry_id] = renault_hub From a573f6094755bc9b991f07c96bd33e6a2e2eb320 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Nov 2023 13:02:44 +0100 Subject: [PATCH 605/982] Rename some check_config test cases (#104244) --- tests/helpers/test_check_config.py | 44 +++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index b83f423e312..5e5343fd43e 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -103,8 +103,8 @@ async def test_config_platform_valid(hass: HomeAssistant) -> None: _assert_warnings_errors(res, [], []) -async def test_component_platform_not_found(hass: HomeAssistant) -> None: - """Test errors if component or platform not found.""" +async def test_integration_not_found(hass: HomeAssistant) -> None: + """Test errors if integration not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} with patch("os.path.isfile", return_value=True), patch_yaml_files(files): @@ -118,8 +118,8 @@ async def test_component_platform_not_found(hass: HomeAssistant) -> None: _assert_warnings_errors(res, [warning], []) -async def test_component_requirement_not_found(hass: HomeAssistant) -> None: - """Test errors if component with a requirement not found not found.""" +async def test_integrationt_requirement_not_found(hass: HomeAssistant) -> None: + """Test errors if integration with a requirement not found not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "test_custom_component:"} with patch( @@ -141,8 +141,8 @@ async def test_component_requirement_not_found(hass: HomeAssistant) -> None: _assert_warnings_errors(res, [warning], []) -async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: - """Test no errors if component not found in recovery mode.""" +async def test_integration_not_found_recovery_mode(hass: HomeAssistant) -> None: + """Test no errors if integration not found in recovery mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} hass.config.recovery_mode = True @@ -154,8 +154,8 @@ async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: _assert_warnings_errors(res, [], []) -async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: - """Test no errors if component not found in safe mode.""" +async def test_integration_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if integration not found in safe mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} hass.config.safe_mode = True @@ -167,8 +167,8 @@ async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: _assert_warnings_errors(res, [], []) -async def test_component_import_error(hass: HomeAssistant) -> None: - """Test errors if component with a requirement not found not found.""" +async def test_integration_import_error(hass: HomeAssistant) -> None: + """Test errors if integration with a requirement not found not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:"} with patch( @@ -188,19 +188,19 @@ async def test_component_import_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("component", "errors", "warnings", "message"), + ("integration", "errors", "warnings", "message"), [ ("frontend", 1, 0, "'blah' is an invalid option for 'frontend'"), ("http", 1, 0, "'blah' is an invalid option for 'http'"), ("logger", 0, 1, "'blah' is an invalid option for 'logger'"), ], ) -async def test_component_schema_error( - hass: HomeAssistant, component: str, errors: int, warnings: int, message: str +async def test_integration_schema_error( + hass: HomeAssistant, integration: str, errors: int, warnings: int, message: str ) -> None: - """Test schema error in component.""" + """Test schema error in integration.""" # Make sure they don't exist - files = {YAML_CONFIG_FILE: BASE_CONFIG + f"frontend:\n{component}:\n blah:"} + files = {YAML_CONFIG_FILE: BASE_CONFIG + f"frontend:\n{integration}:\n blah:"} hass.config.safe_mode = True with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) @@ -215,8 +215,8 @@ async def test_component_schema_error( assert message in warn.message -async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: - """Test errors if component or platform not found.""" +async def test_platform_not_found(hass: HomeAssistant) -> None: + """Test errors if platform not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} with patch("os.path.isfile", return_value=True), patch_yaml_files(files): @@ -293,14 +293,14 @@ async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: ), ], ) -async def test_component_platform_schema_error( +async def test_platform_schema_error( hass: HomeAssistant, extra_config: str, warnings: int, message: str | None, config: dict | None, ) -> None: - """Test schema error in component.""" + """Test schema error in platform.""" comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) mock_integration( @@ -328,7 +328,7 @@ async def test_component_platform_schema_error( assert warn.config == config -async def test_component_config_platform_import_error(hass: HomeAssistant) -> None: +async def test_config_platform_import_error(hass: HomeAssistant) -> None: """Test errors if config platform fails to import.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} @@ -348,8 +348,8 @@ async def test_component_config_platform_import_error(hass: HomeAssistant) -> No _assert_warnings_errors(res, [], [error]) -async def test_component_platform_import_error(hass: HomeAssistant) -> None: - """Test errors if component or platform not found.""" +async def test_platform_import_error(hass: HomeAssistant) -> None: + """Test errors if platform not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: demo"} with patch( From 124e1cebaca0d865b21b804865404eb552223e75 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Nov 2023 14:15:29 +0100 Subject: [PATCH 606/982] Small improvement of config tests (#104243) * Small improvement of config tests * Update snapshots --- tests/snapshots/test_config.ambr | 64 ++++++++++++-------------------- tests/test_config.py | 34 ++++------------- 2 files changed, 32 insertions(+), 66 deletions(-) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 599964d0f4a..7438bda5cde 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -133,14 +133,6 @@ Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 ''', }), - dict({ - 'has_exc_info': True, - 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 1: broken", - }), - dict({ - 'has_exc_info': True, - 'message': 'Unknown error calling custom_validator_bad_2 config validator', - }), ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] @@ -165,14 +157,6 @@ Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 ''', }), - dict({ - 'has_exc_info': True, - 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 1: broken", - }), - dict({ - 'has_exc_info': True, - 'message': 'Unknown error calling custom_validator_bad_2 config validator', - }), ]) # --- # name: test_component_config_validation_error[packages] @@ -233,26 +217,6 @@ # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - dict({ - 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 6: required key 'platform' not provided", - }), - dict({ - 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", - }), - dict({ - 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", - }), - dict({ - 'has_exc_info': False, - 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 - ''', - }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided", @@ -273,10 +237,6 @@ Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo' ''', }), - dict({ - 'has_exc_info': False, - 'message': "Invalid config for 'custom_validator_ok_2' at integrations/custom_validator_ok_2.yaml, line 2: required key 'host' not provided", - }), dict({ 'has_exc_info': True, 'message': "Invalid config for 'custom_validator_bad_1' at integrations/custom_validator_bad_1.yaml, line 2: broken", @@ -285,6 +245,30 @@ 'has_exc_info': True, 'message': 'Unknown error calling custom_validator_bad_2 config validator', }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'custom_validator_ok_2' at integrations/custom_validator_ok_2.yaml, line 2: required key 'host' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 6: required key 'platform' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 + ''', + }), ]) # --- # name: test_component_config_validation_error_with_docs[basic] diff --git a/tests/test_config.py b/tests/test_config.py index 8d74d53c162..448990429a1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1709,19 +1709,10 @@ async def test_component_config_validation_error( ) config = await config_util.async_hass_config_yaml(hass) - for domain in [ - "iot_domain", - "adr_0007_1", - "adr_0007_2", - "adr_0007_3", - "adr_0007_4", - "adr_0007_5", - "custom_validator_ok_1", - "custom_validator_ok_2", - "custom_validator_bad_1", - "custom_validator_bad_2", - ]: - integration = await async_get_integration(hass, domain) + for domain_with_label in config: + integration = await async_get_integration( + hass, domain_with_label.partition(" ")[0] + ) await config_util.async_process_component_config( hass, config, @@ -1763,19 +1754,10 @@ async def test_component_config_validation_error_with_docs( ) config = await config_util.async_hass_config_yaml(hass) - for domain in [ - "iot_domain", - "adr_0007_1", - "adr_0007_2", - "adr_0007_3", - "adr_0007_4", - "adr_0007_5", - "custom_validator_ok_1", - "custom_validator_ok_2", - "custom_validator_bad_1", - "custom_validator_bad_2", - ]: - integration = await async_get_integration(hass, domain) + for domain_with_label in config: + integration = await async_get_integration( + hass, domain_with_label.partition(" ")[0] + ) await config_util.async_process_component_config( hass, config, From 6d7df5ae13042473561803ff78fd73e75a8cc462 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Nov 2023 14:18:05 +0100 Subject: [PATCH 607/982] Update twentemilieu to 2.0.1 (#104250) --- homeassistant/components/twentemilieu/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index 6cb98444be6..aef70aa6a10 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==2.0.0"] + "requirements": ["twentemilieu==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 780d2264f9c..bfa7680599c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2634,7 +2634,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==2.0.0 +twentemilieu==2.0.1 # homeassistant.components.twilio twilio==6.32.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ac9060abab..0e7bf3202d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1950,7 +1950,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==2.0.0 +twentemilieu==2.0.1 # homeassistant.components.twilio twilio==6.32.0 From 923c13907cde2a79e97cea08a32ee1d4b038c96c Mon Sep 17 00:00:00 2001 From: Quentame Date: Mon, 20 Nov 2023 15:01:18 +0100 Subject: [PATCH 608/982] Fix Freebox Home alarm & improve platform tests (#103475) * Fix Freebox Home alarm * Add trigger feature test & fix * FreeboxCallSensor: Add test for missing coverage of new call * Use generator Co-authored-by: Martin Hjelmare * Add test for arm_home feature (questions about the check) * Stay focus on alam tests * can_arm_home ==> if _command_arm_home * Use one liner for supported_features * Add idle state * Fix rebase --------- Co-authored-by: Martin Hjelmare --- .../components/freebox/alarm_control_panel.py | 71 ++---- homeassistant/components/freebox/home_base.py | 9 +- tests/components/freebox/conftest.py | 14 +- tests/components/freebox/const.py | 14 +- ..._values.json => home_alarm_get_value.json} | 2 +- ...et_values.json => home_pir_get_value.json} | 0 .../freebox/fixtures/home_set_value.json | 3 + .../freebox/test_alarm_control_panel.py | 210 +++++++++++------- .../components/freebox/test_binary_sensor.py | 6 +- tests/components/freebox/test_button.py | 2 +- tests/components/freebox/test_init.py | 2 +- tests/components/freebox/test_router.py | 4 +- 12 files changed, 186 insertions(+), 151 deletions(-) rename tests/components/freebox/fixtures/{home_alarm_get_values.json => home_alarm_get_value.json} (64%) rename tests/components/freebox/fixtures/{home_pir_get_values.json => home_pir_get_value.json} (100%) create mode 100644 tests/components/freebox/fixtures/home_set_value.json diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index 52b7109045c..be3d88cf5b4 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -1,5 +1,4 @@ """Support for Freebox alarms.""" -import logging from typing import Any from homeassistant.components.alarm_control_panel import ( @@ -9,7 +8,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, @@ -25,16 +24,14 @@ FREEBOX_TO_STATUS = { "alarm1_arming": STATE_ALARM_ARMING, "alarm2_arming": STATE_ALARM_ARMING, "alarm1_armed": STATE_ALARM_ARMED_AWAY, - "alarm2_armed": STATE_ALARM_ARMED_NIGHT, + "alarm2_armed": STATE_ALARM_ARMED_HOME, "alarm1_alert_timer": STATE_ALARM_TRIGGERED, "alarm2_alert_timer": STATE_ALARM_TRIGGERED, "alert": STATE_ALARM_TRIGGERED, + "idle": STATE_ALARM_DISARMED, } -_LOGGER = logging.getLogger(__name__) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -76,63 +73,33 @@ class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): self._command_state = self.get_command_id( node["type"]["endpoints"], "signal", "state" ) - self._set_features(self._router.home_devices[self._id]) + + self._attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | (AlarmControlPanelEntityFeature.ARM_HOME if self._command_arm_home else 0) + | AlarmControlPanelEntityFeature.TRIGGER + ) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if await self.set_home_endpoint_value(self._command_disarm): - self._set_state(STATE_ALARM_DISARMED) + await self.set_home_endpoint_value(self._command_disarm) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if await self.set_home_endpoint_value(self._command_arm_away): - self._set_state(STATE_ALARM_ARMING) + await self.set_home_endpoint_value(self._command_arm_away) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if await self.set_home_endpoint_value(self._command_arm_home): - self._set_state(STATE_ALARM_ARMING) + await self.set_home_endpoint_value(self._command_arm_home) async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" - if await self.set_home_endpoint_value(self._command_trigger): - self._set_state(STATE_ALARM_TRIGGERED) + await self.set_home_endpoint_value(self._command_trigger) - async def async_update_signal(self): - """Update signal.""" - state = await self.get_home_endpoint_value(self._command_state) - if state: - self._set_state(state) - - def _set_features(self, node: dict[str, Any]) -> None: - """Add alarm features.""" - # Search if the arm home feature is present => has an "alarm2" endpoint - can_arm_home = False - for nodeid, local_node in self._router.home_devices.items(): - if nodeid == local_node["id"]: - alarm2 = next( - filter( - lambda x: (x["name"] == "alarm2" and x["ep_type"] == "signal"), - local_node["show_endpoints"], - ), - None, - ) - if alarm2: - can_arm_home = alarm2["value"] - break - - if can_arm_home: - self._attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_HOME - ) - - else: - self._attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY - - def _set_state(self, state: str) -> None: + async def async_update(self) -> None: """Update state.""" - self._attr_state = FREEBOX_TO_STATUS.get(state) - if not self._attr_state: - self._attr_state = STATE_ALARM_DISARMED - self.async_write_ha_state() + state: str | None = await self.get_home_endpoint_value(self._command_state) + if state: + self._attr_state = FREEBOX_TO_STATUS.get(state) + else: + self._attr_state = None diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index 2cc1a5fcfe3..022528e5ea7 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -131,13 +131,14 @@ class FreeboxHomeEntity(Entity): def get_value(self, ep_type: str, name: str): """Get the value.""" node = next( - filter( - lambda x: (x["name"] == name and x["ep_type"] == ep_type), - self._node["show_endpoints"], + ( + endpoint + for endpoint in self._node["show_endpoints"] + if endpoint["name"] == name and endpoint["ep_type"] == ep_type ), None, ) - if not node: + if node is None: _LOGGER.warning( "The Freebox Home device has no node value for: %s/%s", ep_type, name ) diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 39ed596e6db..3ba175cbc75 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -12,13 +12,14 @@ from .const import ( DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, - DATA_HOME_PIR_GET_VALUES, + DATA_HOME_PIR_GET_VALUE, + DATA_HOME_SET_VALUE, DATA_LAN_GET_HOSTS_LIST, DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, DATA_SYSTEM_GET_CONFIG, - WIFI_GET_GLOBAL_CONFIG, + DATA_WIFI_GET_GLOBAL_CONFIG, ) from tests.common import MockConfigEntry @@ -84,11 +85,16 @@ def mock_router(mock_device_registry_devices): return_value=DATA_CONNECTION_GET_STATUS ) # switch - instance.wifi.get_global_config = AsyncMock(return_value=WIFI_GET_GLOBAL_CONFIG) + instance.wifi.get_global_config = AsyncMock( + return_value=DATA_WIFI_GET_GLOBAL_CONFIG + ) # home devices instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.home.get_home_endpoint_value = AsyncMock( - return_value=DATA_HOME_PIR_GET_VALUES + return_value=DATA_HOME_PIR_GET_VALUE + ) + instance.home.set_home_endpoint_value = AsyncMock( + return_value=DATA_HOME_SET_VALUE ) instance.close = AsyncMock() yield service_mock diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 84667bf9d70..ae07b39c5e8 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -21,7 +21,9 @@ DATA_STORAGE_GET_DISKS = load_json_array_fixture("freebox/storage_get_disks.json DATA_STORAGE_GET_RAIDS = load_json_array_fixture("freebox/storage_get_raids.json") # switch -WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture("freebox/wifi_get_global_config.json") +DATA_WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture( + "freebox/wifi_get_global_config.json" +) # device_tracker DATA_LAN_GET_HOSTS_LIST = load_json_array_fixture("freebox/lan_get_hosts_list.json") @@ -35,10 +37,14 @@ DATA_HOME_GET_NODES = load_json_array_fixture("freebox/home_get_nodes.json") # Home # PIR node id 26, endpoint id 6 -DATA_HOME_PIR_GET_VALUES = load_json_object_fixture("freebox/home_pir_get_values.json") +DATA_HOME_PIR_GET_VALUE = load_json_object_fixture("freebox/home_pir_get_value.json") # Home # ALARM node id 7, endpoint id 11 -DATA_HOME_ALARM_GET_VALUES = load_json_object_fixture( - "freebox/home_alarm_get_values.json" +DATA_HOME_ALARM_GET_VALUE = load_json_object_fixture( + "freebox/home_alarm_get_value.json" ) + +# Home +# Set a node value with success +DATA_HOME_SET_VALUE = load_json_object_fixture("freebox/home_set_value.json") diff --git a/tests/components/freebox/fixtures/home_alarm_get_values.json b/tests/components/freebox/fixtures/home_alarm_get_value.json similarity index 64% rename from tests/components/freebox/fixtures/home_alarm_get_values.json rename to tests/components/freebox/fixtures/home_alarm_get_value.json index 1e43a428296..6e4ad4d0538 100644 --- a/tests/components/freebox/fixtures/home_alarm_get_values.json +++ b/tests/components/freebox/fixtures/home_alarm_get_value.json @@ -1,5 +1,5 @@ { "refresh": 2000, - "value": "alarm2_armed", + "value": "alarm1_armed", "value_type": "string" } diff --git a/tests/components/freebox/fixtures/home_pir_get_values.json b/tests/components/freebox/fixtures/home_pir_get_value.json similarity index 100% rename from tests/components/freebox/fixtures/home_pir_get_values.json rename to tests/components/freebox/fixtures/home_pir_get_value.json diff --git a/tests/components/freebox/fixtures/home_set_value.json b/tests/components/freebox/fixtures/home_set_value.json new file mode 100644 index 00000000000..5550c6db40a --- /dev/null +++ b/tests/components/freebox/fixtures/home_set_value.json @@ -0,0 +1,3 @@ +{ + "success": true +} diff --git a/tests/components/freebox/test_alarm_control_panel.py b/tests/components/freebox/test_alarm_control_panel.py index d24c747f2a3..44286f18b87 100644 --- a/tests/components/freebox/test_alarm_control_panel.py +++ b/tests/components/freebox/test_alarm_control_panel.py @@ -1,57 +1,68 @@ -"""Tests for the Freebox sensors.""" +"""Tests for the Freebox alarms.""" from copy import deepcopy from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -import pytest from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntityFeature, ) from homeassistant.components.freebox import SCAN_INTERVAL from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_ALARM_ARM_AWAY, - SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, - SERVICE_ALARM_ARM_NIGHT, - SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, + STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.state import async_reproduce_state +from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_ALARM_GET_VALUES +from .const import DATA_HOME_ALARM_GET_VALUE, DATA_HOME_GET_NODES -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import async_fire_time_changed -async def test_panel( +async def test_alarm_changed_from_external( hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock ) -> None: - """Test home binary sensors.""" - await setup_platform(hass, ALARM_CONTROL_PANEL) + """Test Freebox Home alarm which state depends on external changes.""" + data_get_home_nodes = deepcopy(DATA_HOME_GET_NODES) + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUE) + + # Add remove arm_home feature + ALARM_NODE_ID = 7 + ALARM_HOME_ENDPOINT_ID = 2 + del data_get_home_nodes[ALARM_NODE_ID]["type"]["endpoints"][ALARM_HOME_ENDPOINT_ID] + router().home.get_home_nodes.return_value = data_get_home_nodes + + data_get_home_endpoint_value["value"] = "alarm1_arming" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + await setup_platform(hass, ALARM_CONTROL_PANEL_DOMAIN) + + # Attributes + assert hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ + "supported_features" + ] == ( + AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.TRIGGER + ) # Initial state - assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "unknown" assert ( - hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ - "supported_features" - ] - == AlarmControlPanelEntityFeature.ARM_AWAY + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMING ) # Now simulate a changed status - data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUES) + data_get_home_endpoint_value["value"] = "alarm1_armed" router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value # Simulate an update @@ -60,64 +71,105 @@ async def test_panel( await hass.async_block_till_done() assert ( - hass.states.get("alarm_control_panel.systeme_d_alarme").state == "armed_night" - ) - # Fake that the entity is triggered. - hass.states.async_set("alarm_control_panel.systeme_d_alarme", STATE_ALARM_DISARMED) - assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "disarmed" - - -async def test_reproducing_states( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test reproducing Alarm control panel states.""" - hass.states.async_set( - "alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY, {} - ) - hass.states.async_set( - "alarm_control_panel.entity_armed_custom_bypass", - STATE_ALARM_ARMED_CUSTOM_BYPASS, - {}, - ) - hass.states.async_set( - "alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME, {} - ) - hass.states.async_set( - "alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT, {} - ) - hass.states.async_set( - "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION, {} - ) - hass.states.async_set( - "alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED, {} - ) - hass.states.async_set( - "alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED, {} + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMED_AWAY ) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_AWAY) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_CUSTOM_BYPASS) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_HOME) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_NIGHT) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_VACATION) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_DISARM) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_TRIGGER) - # These calls should do nothing as entities already in desired state - await async_reproduce_state( - hass, - [ - State("alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY), - State( - "alarm_control_panel.entity_armed_custom_bypass", - STATE_ALARM_ARMED_CUSTOM_BYPASS, - ), - State("alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME), - State("alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT), - State( - "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION - ), - State("alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED), - State("alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED), - ], +async def test_alarm_changed_from_hass(hass: HomeAssistant, router: Mock) -> None: + """Test Freebox Home alarm which state depends on HA.""" + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUE) + + data_get_home_endpoint_value["value"] = "alarm1_armed" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + await setup_platform(hass, ALARM_CONTROL_PANEL_DOMAIN) + + # Attributes + assert hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ + "supported_features" + ] == ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.TRIGGER + ) + + # Initial state: arm_away + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMED_AWAY + ) + + # Now call for a change -> disarmed + data_get_home_endpoint_value["value"] = "idle" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: ["alarm_control_panel.systeme_d_alarme"]}, + blocking=True, + ) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_DISARMED + ) + + # Now call for a change -> arm_away + data_get_home_endpoint_value["value"] = "alarm1_arming" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: ["alarm_control_panel.systeme_d_alarme"]}, + blocking=True, + ) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMING + ) + + # Now call for a change -> arm_home + data_get_home_endpoint_value["value"] = "alarm2_armed" + # in reality: alarm2_arming then alarm2_armed + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_HOME, + {ATTR_ENTITY_ID: ["alarm_control_panel.systeme_d_alarme"]}, + blocking=True, + ) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMED_HOME + ) + + # Now call for a change -> trigger + data_get_home_endpoint_value["value"] = "alarm1_alert_timer" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_TRIGGER, + {ATTR_ENTITY_ID: ["alarm_control_panel.systeme_d_alarme"]}, + blocking=True, + ) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_TRIGGERED + ) + + +async def test_alarm_undefined_fetch_status(hass: HomeAssistant, router: Mock) -> None: + """Test Freebox Home alarm which state is undefined or null.""" + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUE) + data_get_home_endpoint_value["value"] = None + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + await setup_platform(hass, ALARM_CONTROL_PANEL_DOMAIN) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state == STATE_UNKNOWN ) diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index 2fd308ea667..ee07af786be 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -1,4 +1,4 @@ -"""Tests for the Freebox sensors.""" +"""Tests for the Freebox binary sensors.""" from copy import deepcopy from unittest.mock import Mock @@ -13,7 +13,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_PIR_GET_VALUES, DATA_STORAGE_GET_RAIDS +from .const import DATA_HOME_PIR_GET_VALUE, DATA_STORAGE_GET_RAIDS from tests.common import async_fire_time_changed @@ -73,7 +73,7 @@ async def test_home( assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off" # Now simulate a changed status - data_home_get_values_changed = deepcopy(DATA_HOME_PIR_GET_VALUES) + data_home_get_values_changed = deepcopy(DATA_HOME_PIR_GET_VALUE) data_home_get_values_changed["value"] = True router().home.get_home_endpoint_value.return_value = data_home_get_values_changed diff --git a/tests/components/freebox/test_button.py b/tests/components/freebox/test_button.py index 5f72b5968f1..209ab1e9fc2 100644 --- a/tests/components/freebox/test_button.py +++ b/tests/components/freebox/test_button.py @@ -1,4 +1,4 @@ -"""Tests for the Freebox config flow.""" +"""Tests for the Freebox buttons.""" from unittest.mock import ANY, AsyncMock, Mock, patch from pytest_unordered import unordered diff --git a/tests/components/freebox/test_init.py b/tests/components/freebox/test_init.py index 85acfdccc4d..9064727fb7f 100644 --- a/tests/components/freebox/test_init.py +++ b/tests/components/freebox/test_init.py @@ -1,4 +1,4 @@ -"""Tests for the Freebox config flow.""" +"""Tests for the Freebox init.""" from unittest.mock import ANY, Mock, patch from pytest_unordered import unordered diff --git a/tests/components/freebox/test_router.py b/tests/components/freebox/test_router.py index 595aab24fc9..572c168e665 100644 --- a/tests/components/freebox/test_router.py +++ b/tests/components/freebox/test_router.py @@ -3,7 +3,7 @@ import json from homeassistant.components.freebox.router import is_json -from .const import DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, WIFI_GET_GLOBAL_CONFIG +from .const import DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, DATA_WIFI_GET_GLOBAL_CONFIG async def test_is_json() -> None: @@ -12,7 +12,7 @@ async def test_is_json() -> None: # Valid JSON values assert is_json("{}") assert is_json('{ "simple":"json" }') - assert is_json(json.dumps(WIFI_GET_GLOBAL_CONFIG)) + assert is_json(json.dumps(DATA_WIFI_GET_GLOBAL_CONFIG)) assert is_json(json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)) # Not valid JSON values From 9c5e0fc2c93c597510ddacee9c31ea988c960ac2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:13:52 +0100 Subject: [PATCH 609/982] Discover new added device at runtime in AVM Fritz!Smarthome (#103859) --- .../components/fritzbox/binary_sensor.py | 34 ++++++++----- homeassistant/components/fritzbox/button.py | 28 ++++++---- homeassistant/components/fritzbox/climate.py | 34 ++++++++----- homeassistant/components/fritzbox/common.py | 16 ++++++ .../components/fritzbox/coordinator.py | 7 +++ homeassistant/components/fritzbox/cover.py | 31 +++++++---- homeassistant/components/fritzbox/light.py | 51 ++++++++----------- homeassistant/components/fritzbox/sensor.py | 31 +++++++---- homeassistant/components/fritzbox/switch.py | 33 +++++++----- tests/components/fritzbox/__init__.py | 11 ++++ .../components/fritzbox/test_binary_sensor.py | 25 ++++++++- tests/components/fritzbox/test_button.py | 29 ++++++++++- tests/components/fritzbox/test_climate.py | 25 ++++++++- tests/components/fritzbox/test_cover.py | 29 ++++++++++- tests/components/fritzbox/test_light.py | 37 +++++++++++++- tests/components/fritzbox/test_sensor.py | 25 ++++++++- tests/components/fritzbox/test_switch.py | 25 ++++++++- 17 files changed, 362 insertions(+), 109 deletions(-) create mode 100644 homeassistant/components/fritzbox/common.py diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 5d30362627e..3075ab2d01d 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -14,12 +14,11 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxDeviceEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN -from .coordinator import FritzboxDataUpdateCoordinator +from .common import get_coordinator from .model import FritzEntityDescriptionMixinBase @@ -68,18 +67,25 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome binary sensor from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [ - FritzboxBinarySensor(coordinator, ain, description) - for ain, device in coordinator.data.devices.items() - for description in BINARY_SENSOR_TYPES - if description.suitable(device) - ] - ) + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( + [ + FritzboxBinarySensor(coordinator, ain, description) + for ain in coordinator.new_devices + for description in BINARY_SENSOR_TYPES + if description.suitable(coordinator.data.devices[ain]) + ] + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxBinarySensor(FritzBoxDeviceEntity, BinarySensorEntity): diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index cc5457fb8a2..d5331642325 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -3,25 +3,33 @@ from pyfritzhome.devicetypes import FritzhomeTemplate from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from . import FritzBoxEntity +from .common import get_coordinator +from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome template from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [FritzBoxTemplate(coordinator, ain) for ain in coordinator.data.templates] - ) + @callback + def _add_entities() -> None: + """Add templates.""" + if not coordinator.new_templates: + return + async_add_entities( + [FritzBoxTemplate(coordinator, ain) for ain in coordinator.new_templates] + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzBoxTemplate(FritzBoxEntity, ButtonEntity): @@ -37,7 +45,7 @@ class FritzBoxTemplate(FritzBoxEntity, ButtonEntity): """Return device specific attributes.""" return DeviceInfo( name=self.data.name, - identifiers={(FRITZBOX_DOMAIN, self.ain)}, + identifiers={(DOMAIN, self.ain)}, configuration_url=self.coordinator.configuration_url, manufacturer="AVM", model="SmartHome Template", diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 7c846789637..fa1873bc379 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -18,17 +18,16 @@ from homeassistant.const import ( PRECISION_HALVES, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity +from . import FritzBoxDeviceEntity +from .common import get_coordinator from .const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, ATTR_STATE_WINDOW_OPEN, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, ) from .model import ClimateExtraAttributes @@ -50,17 +49,24 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome thermostat from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [ - FritzboxThermostat(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_thermostat - ] - ) + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( + [ + FritzboxThermostat(coordinator, ain) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_thermostat + ] + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): diff --git a/homeassistant/components/fritzbox/common.py b/homeassistant/components/fritzbox/common.py new file mode 100644 index 00000000000..ab87a51f9ce --- /dev/null +++ b/homeassistant/components/fritzbox/common.py @@ -0,0 +1,16 @@ +"""Common functions for fritzbox integration.""" + +from homeassistant.core import HomeAssistant + +from .const import CONF_COORDINATOR, DOMAIN +from .coordinator import FritzboxDataUpdateCoordinator + + +def get_coordinator( + hass: HomeAssistant, config_entry_id: str +) -> FritzboxDataUpdateCoordinator: + """Get coordinator for given config entry id.""" + coordinator: FritzboxDataUpdateCoordinator = hass.data[DOMAIN][config_entry_id][ + CONF_COORDINATOR + ] + return coordinator diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 194825e602f..f6d210e367a 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -37,6 +37,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.fritz: Fritzhome = hass.data[DOMAIN][self.entry.entry_id][CONF_CONNECTIONS] self.configuration_url = self.fritz.get_prefixed_host() self.has_templates = has_templates + self.new_devices: set[str] = set() + self.new_templates: set[str] = set() super().__init__( hass, @@ -45,6 +47,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat update_interval=timedelta(seconds=30), ) + self.data = FritzboxCoordinatorData({}, {}) + def _update_fritz_devices(self) -> FritzboxCoordinatorData: """Update all fritzbox device data.""" try: @@ -87,6 +91,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat for template in templates: template_data[template.ain] = template + self.new_devices = device_data.keys() - self.data.devices.keys() + self.new_templates = template_data.keys() - self.data.templates.keys() + return FritzboxCoordinatorData(devices=device_data, templates=template_data) async def _async_update_data(self) -> FritzboxCoordinatorData: diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index df3b1562f9b..431b33f1b49 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -10,26 +10,35 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from . import FritzBoxDeviceEntity +from .common import get_coordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome cover from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - FritzboxCover(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_blind - ) + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( + [ + FritzboxCover(coordinator, ain) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_blind + ] + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxCover(FritzBoxDeviceEntity, CoverEntity): diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index f83dd454592..f78a0a60efc 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -13,17 +13,12 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity -from .const import ( - COLOR_MODE, - COLOR_TEMP_MODE, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, - LOGGER, -) +from .common import get_coordinator +from .const import COLOR_MODE, COLOR_TEMP_MODE, LOGGER SUPPORTED_COLOR_MODES = {ColorMode.COLOR_TEMP, ColorMode.HS} @@ -32,31 +27,29 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome light from ConfigEntry.""" - entities: list[FritzboxLight] = [] - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - for ain, device in coordinator.data.devices.items(): - if not device.has_lightbulb: - continue - - supported_color_temps = await hass.async_add_executor_job( - device.get_color_temps + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( + [ + FritzboxLight( + coordinator, + ain, + device.get_colors(), + device.get_color_temps(), + ) + for ain in coordinator.new_devices + if (device := coordinator.data.devices[ain]).has_lightbulb + ] ) - supported_colors = await hass.async_add_executor_job(device.get_colors) + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - entities.append( - FritzboxLight( - coordinator, - ain, - supported_colors, - supported_color_temps, - ) - ) - - async_add_entities(entities) + _add_entities() class FritzboxLight(FritzBoxDeviceEntity, LightEntity): diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 013c1dfc7b5..35456b7598b 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -25,13 +25,13 @@ from homeassistant.const import ( UnitOfPower, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxDeviceEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .common import get_coordinator from .model import FritzEntityDescriptionMixinBase @@ -212,16 +212,25 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome sensor from ConfigEntry.""" - coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [ - FritzBoxSensor(coordinator, ain, description) - for ain, device in coordinator.data.devices.items() - for description in SENSOR_TYPES - if description.suitable(device) - ] - ) + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( + [ + FritzBoxSensor(coordinator, ain, description) + for ain in coordinator.new_devices + for description in SENSOR_TYPES + if description.suitable(coordinator.data.devices[ain]) + ] + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzBoxSensor(FritzBoxDeviceEntity, SensorEntity): diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 5eee3019633..cf7dcb6e3b9 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -5,28 +5,35 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from . import FritzBoxDeviceEntity +from .common import get_coordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome switch from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [ - FritzboxSwitch(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_switch - ] - ) + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( + [ + FritzboxSwitch(coordinator, ain) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_switch + ] + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 15ff04f3720..1faf37c84ee 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -45,6 +45,17 @@ async def setup_config_entry( return result +def set_devices( + fritz: Mock, devices: list[Mock] | None = None, templates: list[Mock] | None = None +) -> None: + """Set list of devices or templates.""" + if devices is not None: + fritz().get_devices.return_value = devices + + if templates is not None: + fritz().get_templates.return_value = templates + + class FritzEntityBaseMock(Mock): """base mock of a AVM Fritz!Box binary sensor device.""" diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index ac6b702147a..983516bb9c0 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import FritzDeviceBinarySensorMock, setup_config_entry +from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -126,3 +126,26 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 1 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceBinarySensorMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(f"{ENTITY_ID}_alarm") + assert state + + new_device = FritzDeviceBinarySensorMock() + new_device.ain = "7890 1234" + new_device.name = "new_device" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_device_alarm") + assert state diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 9c53c895f5d..8c0bbec573e 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -1,4 +1,5 @@ """Tests for AVM Fritz!Box templates.""" +from datetime import timedelta from unittest.mock import Mock from homeassistant.components.button import DOMAIN, SERVICE_PRESS @@ -10,10 +11,13 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util -from . import FritzEntityBaseMock, setup_config_entry +from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG +from tests.common import async_fire_time_changed + ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" @@ -41,3 +45,26 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert fritz().apply_template.call_count == 1 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + template = FritzEntityBaseMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_template = FritzEntityBaseMock() + new_template.ain = "7890 1234" + new_template.name = "new_template" + set_devices(fritz, templates=[template, new_template]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_template") + assert state diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index d49b5710a12..a14c53d6529 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -41,7 +41,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import FritzDeviceClimateMock, setup_config_entry +from . import FritzDeviceClimateMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -402,3 +402,26 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().update_devices.call_count == 3 assert state assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceClimateMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceClimateMock() + new_device.ain = "7890 1234" + new_device.name = "new_climate" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_climate") + assert state diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index af725ce93da..e3a6d786abf 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -1,4 +1,5 @@ """Tests for AVM Fritz!Box switch component.""" +from datetime import timedelta from unittest.mock import Mock, call from homeassistant.components.cover import ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN @@ -12,10 +13,13 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util -from . import FritzDeviceCoverMock, setup_config_entry +from . import FritzDeviceCoverMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG +from tests.common import async_fire_time_changed + ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" @@ -84,3 +88,26 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_blind_stop.call_count == 1 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceCoverMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceCoverMock() + new_device.ain = "7890 1234" + new_device.name = "new_climate" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_climate") + assert state diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 5511b93ac3f..858b564cd18 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -29,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import FritzDeviceLightMock, setup_config_entry +from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -262,3 +262,38 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().update_devices.call_count == 4 assert fritz().login.call_count == 4 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + device.color_mode = COLOR_TEMP_MODE + device.color_temp = 2700 + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceLightMock() + new_device.ain = "7890 1234" + new_device.name = "new_light" + new_device.get_color_temps.return_value = [2700, 6500] + new_device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + new_device.color_mode = COLOR_TEMP_MODE + new_device.color_temp = 2700 + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_light") + assert state diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index b363d966c01..9fe25d02ed0 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from . import FritzDeviceSensorMock, setup_config_entry +from . import FritzDeviceSensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -108,3 +108,26 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().update_devices.call_count == 4 assert fritz().login.call_count == 4 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceSensorMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(f"{ENTITY_ID}_temperature") + assert state + + new_device = FritzDeviceSensorMock() + new_device.ain = "7890 1234" + new_device.name = "new_device" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_device_temperature") + assert state diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 4ed1a88190a..aefe21e3ffc 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from . import FritzDeviceSwitchMock, setup_config_entry +from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -187,3 +187,26 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No state = hass.states.get(ENTITY_ID) assert state assert state.state == STATE_UNAVAILABLE + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceSwitchMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceSwitchMock() + new_device.ain = "7890 1234" + new_device.name = "new_switch" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_switch") + assert state From cd5595a1304a8cfc3c01b29f369162b054413b8b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Nov 2023 18:13:37 +0100 Subject: [PATCH 610/982] Use send_json_auto_id in todo tests (#104245) * Use send_json_auto_id in todo tests * Update tests --- tests/components/google_tasks/test_todo.py | 21 ++--------- tests/components/local_todo/test_todo.py | 27 ++------------ tests/components/shopping_list/test_todo.py | 39 +++------------------ 3 files changed, 9 insertions(+), 78 deletions(-) diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 7b11372f1d4..70309e64222 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -63,39 +63,22 @@ def platforms() -> list[str]: return [Platform.TODO] -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next_id() -> int: - nonlocal id - id += 1 - return id - - return next_id - - @pytest.fixture async def ws_get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator, ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { - "id": id, "type": "todo/item/list", "entity_id": ENTITY_ID, } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 5747e05ad05..c6246be3dad 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -13,39 +13,22 @@ from .conftest import TEST_ENTITY from tests.typing import WebSocketGenerator -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next() -> int: - nonlocal id - id += 1 - return id - - return next - - @pytest.fixture async def ws_get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator, ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { - "id": id, "type": "todo/item/list", "entity_id": TEST_ENTITY, } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) @@ -55,25 +38,21 @@ async def ws_get_items( @pytest.fixture async def ws_move_item( hass_ws_client: WebSocketGenerator, - ws_req_id: Callable[[], int], ) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" async def move(uid: str, previous_uid: str | None) -> None: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() data = { - "id": id, "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": uid, } if previous_uid is not None: data["previous_uid"] = previous_uid - await client.send_json(data) + await client.send_json_auto_id(data) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return move diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 681ccea60ac..7c13344ad1d 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -13,39 +13,22 @@ from tests.typing import WebSocketGenerator TEST_ENTITY = "todo.shopping_list" -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next() -> int: - nonlocal id - id += 1 - return id - - return next - - @pytest.fixture async def ws_get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator, ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { - "id": id, "type": "todo/item/list", "entity_id": TEST_ENTITY, } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) @@ -55,25 +38,21 @@ async def ws_get_items( @pytest.fixture async def ws_move_item( hass_ws_client: WebSocketGenerator, - ws_req_id: Callable[[], int], ) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" async def move(uid: str, previous_uid: str | None) -> dict[str, Any]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() data = { - "id": id, "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": uid, } if previous_uid is not None: data["previous_uid"] = previous_uid - await client.send_json(data) + await client.send_json_auto_id(data) resp = await client.receive_json() - assert resp.get("id") == id return resp return move @@ -83,7 +62,6 @@ async def test_get_items( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test creating a shopping list item with the WS API and verifying with To-do API.""" @@ -94,9 +72,7 @@ async def test_get_items( assert state.state == "0" # Native shopping list websocket - await client.send_json( - {"id": ws_req_id(), "type": "shopping_list/items/add", "name": "soda"} - ) + await client.send_json_auto_id({"type": "shopping_list/items/add", "name": "soda"}) msg = await client.receive_json() assert msg["success"] is True data = msg["result"] @@ -117,7 +93,6 @@ async def test_get_items( async def test_add_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test adding shopping_list item and listing it.""" @@ -145,7 +120,6 @@ async def test_add_item( async def test_remove_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test removing a todo item.""" @@ -187,7 +161,6 @@ async def test_remove_item( async def test_bulk_remove( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test removing a todo item.""" @@ -232,7 +205,6 @@ async def test_bulk_remove( async def test_update_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test updating a todo item.""" @@ -286,7 +258,6 @@ async def test_update_item( async def test_partial_update_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test updating a todo item with partial information.""" @@ -363,7 +334,6 @@ async def test_partial_update_item( async def test_update_invalid_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test updating a todo item that does not exist.""" @@ -410,7 +380,6 @@ async def test_update_invalid_item( async def test_move_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ws_move_item: Callable[[str, str | None], Awaitable[dict[str, Any]]], src_idx: int, From ce497dd7ed1591647d6d014f8537a5fcdaa1b4f3 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 20 Nov 2023 18:30:39 +0100 Subject: [PATCH 611/982] Use entity description for Reolink cameras (#104139) * Use entity description for cams * expend for loops --- homeassistant/components/reolink/camera.py | 133 +++++++++++++----- homeassistant/components/reolink/strings.json | 56 ++++++++ 2 files changed, 156 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index b012649ec4c..1bb1c14374c 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,11 +1,17 @@ """Component providing support for Reolink IP cameras.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging -from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.api import DUAL_LENS_MODELS, Host -from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -17,6 +23,70 @@ from .entity import ReolinkChannelCoordinatorEntity _LOGGER = logging.getLogger(__name__) +@dataclass(kw_only=True) +class ReolinkCameraEntityDescription( + CameraEntityDescription, +): + """A class that describes camera entities for a camera channel.""" + + stream: str + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +CAMERA_ENTITIES = ( + ReolinkCameraEntityDescription( + key="sub", + stream="sub", + translation_key="sub", + ), + ReolinkCameraEntityDescription( + key="main", + stream="main", + translation_key="main", + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="snapshots_sub", + stream="snapshots_sub", + translation_key="snapshots_sub", + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="snapshots", + stream="snapshots_main", + translation_key="snapshots_main", + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="ext", + stream="ext", + translation_key="ext", + supported=lambda api, ch: api.protocol in ["rtmp", "flv"], + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="autotrack_sub", + stream="autotrack_sub", + translation_key="autotrack_sub", + supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + ), + ReolinkCameraEntityDescription( + key="autotrack_snapshots_sub", + stream="autotrack_snapshots_sub", + translation_key="autotrack_snapshots_sub", + supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="autotrack_snapshots_main", + stream="autotrack_snapshots_main", + translation_key="autotrack_snapshots_main", + supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -24,62 +94,59 @@ async def async_setup_entry( ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - host = reolink_data.host - cameras = [] - for channel in host.api.stream_channels: - streams = ["sub", "main", "snapshots_sub", "snapshots_main"] - if host.api.protocol in ["rtmp", "flv"]: - streams.append("ext") - - if host.api.supported(channel, "autotrack_stream"): - streams.extend( - ["autotrack_sub", "autotrack_snapshots_sub", "autotrack_snapshots_main"] - ) - - for stream in streams: - stream_url = await host.api.get_stream_source(channel, stream) - if stream_url is None and "snapshots" not in stream: + entities: list[ReolinkCamera] = [] + for entity_description in CAMERA_ENTITIES: + for channel in reolink_data.host.api.stream_channels: + if not entity_description.supported(reolink_data.host.api, channel): + continue + stream_url = await reolink_data.host.api.get_stream_source( + channel, entity_description.stream + ) + if stream_url is None and "snapshots" not in entity_description.stream: continue - cameras.append(ReolinkCamera(reolink_data, channel, stream)) - async_add_entities(cameras) + entities.append(ReolinkCamera(reolink_data, channel, entity_description)) + + async_add_entities(entities) class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM + entity_description: ReolinkCameraEntityDescription def __init__( self, reolink_data: ReolinkData, channel: int, - stream: str, + entity_description: ReolinkCameraEntityDescription, ) -> None: """Initialize Reolink camera stream.""" + self.entity_description = entity_description ReolinkChannelCoordinatorEntity.__init__(self, reolink_data, channel) Camera.__init__(self) - self._stream = stream - - stream_name = self._stream.replace("_", " ") if self._host.api.model in DUAL_LENS_MODELS: - self._attr_name = f"{stream_name} lens {self._channel}" - else: - self._attr_name = stream_name - stream_id = self._stream - if stream_id == "snapshots_main": - stream_id = "snapshots" - self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{stream_id}" - self._attr_entity_registry_enabled_default = stream in ["sub", "autotrack_sub"] + self._attr_translation_key = ( + f"{entity_description.translation_key}_lens_{self._channel}" + ) + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) async def stream_source(self) -> str | None: """Return the source of the stream.""" - return await self._host.api.get_stream_source(self._channel, self._stream) + return await self._host.api.get_stream_source( + self._channel, self.entity_description.stream + ) async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - return await self._host.api.get_snapshot(self._channel, self._stream) + return await self._host.api.get_snapshot( + self._channel, self.entity_description.stream + ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 4170626b547..bab2802720d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -147,6 +147,62 @@ "name": "Guard set current position" } }, + "camera": { + "sub": { + "name": "Fluent" + }, + "main": { + "name": "Clear" + }, + "snapshots_sub": { + "name": "Snapshots fluent" + }, + "snapshots_main": { + "name": "Snapshots clear" + }, + "ext": { + "name": "Balanced" + }, + "sub_lens_0": { + "name": "Fluent lens 0" + }, + "main_lens_0": { + "name": "Clear lens 0" + }, + "snapshots_sub_lens_0": { + "name": "Snapshots fluent lens 0" + }, + "snapshots_main_lens_0": { + "name": "Snapshots clear lens 0" + }, + "ext_lens_0": { + "name": "Balanced lens 0" + }, + "sub_lens_1": { + "name": "Fluent lens 1" + }, + "main_lens_1": { + "name": "Clear lens 1" + }, + "snapshots_sub_lens_1": { + "name": "Snapshots fluent lens 1" + }, + "snapshots_main_lens_1": { + "name": "Snapshots clear lens 1" + }, + "ext_lens_1": { + "name": "Balanced lens 1" + }, + "autotrack_sub": { + "name": "Autotrack fluent" + }, + "autotrack_snapshots_sub": { + "name": "Autotrack snapshots fluent" + }, + "autotrack_snapshots_main": { + "name": "Autotrack snapshots clear" + } + }, "light": { "floodlight": { "name": "Floodlight" From f69045fb64e41c714615708b351ea444790313ce Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 20 Nov 2023 19:02:02 +0100 Subject: [PATCH 612/982] Address late fritzbox coordinator runtime device discover review comments (#104267) replace list comprehension by generator expression --- .../components/fritzbox/binary_sensor.py | 10 ++++------ homeassistant/components/fritzbox/button.py | 2 +- homeassistant/components/fritzbox/climate.py | 8 +++----- homeassistant/components/fritzbox/cover.py | 8 +++----- homeassistant/components/fritzbox/light.py | 18 ++++++++---------- homeassistant/components/fritzbox/sensor.py | 10 ++++------ homeassistant/components/fritzbox/switch.py | 8 +++----- 7 files changed, 26 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 3075ab2d01d..2460635351e 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -75,12 +75,10 @@ async def async_setup_entry( if not coordinator.new_devices: return async_add_entities( - [ - FritzboxBinarySensor(coordinator, ain, description) - for ain in coordinator.new_devices - for description in BINARY_SENSOR_TYPES - if description.suitable(coordinator.data.devices[ain]) - ] + FritzboxBinarySensor(coordinator, ain, description) + for ain in coordinator.new_devices + for description in BINARY_SENSOR_TYPES + if description.suitable(coordinator.data.devices[ain]) ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index d5331642325..732c41bfb7d 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -24,7 +24,7 @@ async def async_setup_entry( if not coordinator.new_templates: return async_add_entities( - [FritzBoxTemplate(coordinator, ain) for ain in coordinator.new_templates] + FritzBoxTemplate(coordinator, ain) for ain in coordinator.new_templates ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index fa1873bc379..70359d9b2af 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -57,11 +57,9 @@ async def async_setup_entry( if not coordinator.new_devices: return async_add_entities( - [ - FritzboxThermostat(coordinator, ain) - for ain in coordinator.new_devices - if coordinator.data.devices[ain].has_thermostat - ] + FritzboxThermostat(coordinator, ain) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_thermostat ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index 431b33f1b49..7d27356fdf9 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -29,11 +29,9 @@ async def async_setup_entry( if not coordinator.new_devices: return async_add_entities( - [ - FritzboxCover(coordinator, ain) - for ain in coordinator.new_devices - if coordinator.data.devices[ain].has_blind - ] + FritzboxCover(coordinator, ain) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_blind ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index f78a0a60efc..d31ccd180c4 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -35,16 +35,14 @@ async def async_setup_entry( if not coordinator.new_devices: return async_add_entities( - [ - FritzboxLight( - coordinator, - ain, - device.get_colors(), - device.get_color_temps(), - ) - for ain in coordinator.new_devices - if (device := coordinator.data.devices[ain]).has_lightbulb - ] + FritzboxLight( + coordinator, + ain, + device.get_colors(), + device.get_color_temps(), + ) + for ain in coordinator.new_devices + if (device := coordinator.data.devices[ain]).has_lightbulb ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 35456b7598b..1e5d7754934 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -220,12 +220,10 @@ async def async_setup_entry( if not coordinator.new_devices: return async_add_entities( - [ - FritzBoxSensor(coordinator, ain, description) - for ain in coordinator.new_devices - for description in SENSOR_TYPES - if description.suitable(coordinator.data.devices[ain]) - ] + FritzBoxSensor(coordinator, ain, description) + for ain in coordinator.new_devices + for description in SENSOR_TYPES + if description.suitable(coordinator.data.devices[ain]) ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index cf7dcb6e3b9..617a5242c5b 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -24,11 +24,9 @@ async def async_setup_entry( if not coordinator.new_devices: return async_add_entities( - [ - FritzboxSwitch(coordinator, ain) - for ain in coordinator.new_devices - if coordinator.data.devices[ain].has_switch - ] + FritzboxSwitch(coordinator, ain) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_switch ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) From 58a73f77230cfa1bbaa0e6176002acd1b1e12eb3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Nov 2023 19:27:55 +0100 Subject: [PATCH 613/982] Update elgato to 5.1.1 (#104252) --- homeassistant/components/elgato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 033a2567bb4..0671a7adb1d 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["elgato==5.1.0"], + "requirements": ["elgato==5.1.1"], "zeroconf": ["_elg._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bfa7680599c..73067d35309 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -736,7 +736,7 @@ ecoaliface==0.4.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.1.0 +elgato==5.1.1 # homeassistant.components.eliqonline eliqonline==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e7bf3202d4..d0437162b21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -596,7 +596,7 @@ easyenergy==0.3.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.1.0 +elgato==5.1.1 # homeassistant.components.elkm1 elkm1-lib==2.2.6 From 80f8e76fa32e82785b95335cfab4f421319e17ca Mon Sep 17 00:00:00 2001 From: Anton Tolchanov <1687799+knyar@users.noreply.github.com> Date: Mon, 20 Nov 2023 18:53:25 +0000 Subject: [PATCH 614/982] Handle attributes set to None in prometheus (#104247) Better handle attributes set to None --- .../components/prometheus/__init__.py | 10 ++++++---- tests/components/prometheus/test_init.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 1ce16caa6e1..7beac4cc54b 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components.climate import ( from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -323,14 +324,14 @@ class PrometheusMetrics: } def _battery(self, state): - if "battery_level" in state.attributes: + if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None: metric = self._metric( "battery_level_percent", self.prometheus_cli.Gauge, "Battery level as a percentage of its capacity", ) try: - value = float(state.attributes[ATTR_BATTERY_LEVEL]) + value = float(battery_level) metric.labels(**self._labels(state)).set(value) except ValueError: pass @@ -440,8 +441,9 @@ class PrometheusMetrics: ) try: - if "brightness" in state.attributes and state.state == STATE_ON: - value = state.attributes["brightness"] / 255.0 + brightness = state.attributes.get(ATTR_BRIGHTNESS) + if state.state == STATE_ON and brightness is not None: + value = brightness / 255.0 else: value = self.state_as_number(state) value = value * 100 diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 1e14ab848a0..af2f2ba5784 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -491,6 +491,12 @@ async def test_light(client, light_entities) -> None: 'friendly_name="PC"} 70.58823529411765' in body ) + assert ( + 'light_brightness_percent{domain="light",' + 'entity="light.hallway",' + 'friendly_name="Hallway"} 100.0' in body + ) + @pytest.mark.parametrize("namespace", [""]) async def test_lock(client, lock_entities) -> None: @@ -1557,6 +1563,19 @@ async def light_fixture( data["light_4"] = light_4 data["light_4_attributes"] = light_4_attributes + light_5 = entity_registry.async_get_or_create( + domain=light.DOMAIN, + platform="test", + unique_id="light_5", + suggested_object_id="hallway", + original_name="Hallway", + ) + # Light is on, but brightness is unset; expect metrics to report + # brightness of 100%. + light_5_attributes = {light.ATTR_BRIGHTNESS: None} + set_state_with_entry(hass, light_5, STATE_ON, light_5_attributes) + data["light_5"] = light_5 + data["light_5_attributes"] = light_5_attributes await hass.async_block_till_done() return data From 4da77d2253e4e9cf5b581486fa934f285ae6ed81 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Nov 2023 19:55:10 +0100 Subject: [PATCH 615/982] Use more specific exception type for imap decoding (#104227) * Use more specific exception type for imap decoding * Only catch ValueError --- homeassistant/components/imap/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index d77f7fb05bb..34286ce49fa 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -167,7 +167,7 @@ class ImapMessage: """ try: return str(part.get_payload(decode=True).decode(self._charset)) - except Exception: # pylint: disable=broad-except + except ValueError: return str(part.get_payload()) part: Message From b7f8ddb04c0b0776906807dd91a156a9fc61e5d2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Nov 2023 19:58:22 +0100 Subject: [PATCH 616/982] Update pvo to 2.1.1 (#104271) --- homeassistant/components/pvoutput/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 9e66d79d2bd..61bd6fd6164 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==2.1.0"] + "requirements": ["pvo==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 73067d35309..954181e6c38 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1517,7 +1517,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.1.0 +pvo==2.1.1 # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0437162b21..7280306f0ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1157,7 +1157,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.1.0 +pvo==2.1.1 # homeassistant.components.canary py-canary==0.5.3 From 5fe5057b15b0638ffcad3e2e8f5817673482229e Mon Sep 17 00:00:00 2001 From: Blastoise186 <40033667+blastoise186@users.noreply.github.com> Date: Mon, 20 Nov 2023 19:50:15 +0000 Subject: [PATCH 617/982] Bump yt-dlp to 2023.11.16 (#104255) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index d16439800a9..111509c1f31 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2023.10.13"] + "requirements": ["yt-dlp==2023.11.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 954181e6c38..034c4ba3755 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2799,7 +2799,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.10.13 +yt-dlp==2023.11.16 # homeassistant.components.zamg zamg==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7280306f0ce..25becb876b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2091,7 +2091,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.10.13 +yt-dlp==2023.11.16 # homeassistant.components.zamg zamg==0.3.0 From 5527cbd78ac7bbe25bed204f3e9b83095d0bd807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Mon, 20 Nov 2023 22:38:16 +0100 Subject: [PATCH 618/982] Fix default lock code for lock services (#103463) * verisure: Support default code from lock entity * Actually use default lock code * Typing * Only pass default code if set * Avoid passing code as empty string * Simplified code --- homeassistant/components/lock/__init__.py | 38 ++++----- tests/components/lock/test_init.py | 95 +++++++++++++++++++++++ 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 8cbce69dc7c..ed7e2070055 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -87,40 +87,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: - """Lock the lock.""" - code: str = service_call.data.get( - ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access - ) +@callback +def _add_default_code(entity: LockEntity, service_call: ServiceCall) -> dict[Any, Any]: + data = remove_entity_service_fields(service_call) + code: str = data.pop(ATTR_CODE, "") + if not code: + code = entity._lock_option_default_code # pylint: disable=protected-access if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" ) - await entity.async_lock(**remove_entity_service_fields(service_call)) + if code: + data[ATTR_CODE] = code + return data + + +async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: + """Lock the lock.""" + await entity.async_lock(**_add_default_code(entity, service_call)) async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: """Unlock the lock.""" - code: str = service_call.data.get( - ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access - ) - if entity.code_format_cmp and not entity.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}" - ) - await entity.async_unlock(**remove_entity_service_fields(service_call)) + await entity.async_unlock(**_add_default_code(entity, service_call)) async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: """Open the door latch.""" - code: str = service_call.data.get( - ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access - ) - if entity.code_format_cmp and not entity.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}" - ) - await entity.async_open(**remove_entity_service_fields(service_call)) + await entity.async_open(**_add_default_code(entity, service_call)) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 31ad8fc60ac..16f40fda786 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -42,6 +42,8 @@ class MockLockEntity(LockEntity): ) -> None: """Initialize mock lock entity.""" self._attr_supported_features = supported_features + self.calls_lock = MagicMock() + self.calls_unlock = MagicMock() self.calls_open = MagicMock() if code_format is not None: self._attr_code_format = code_format @@ -49,11 +51,13 @@ class MockLockEntity(LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" + self.calls_lock(kwargs) self._attr_is_locking = False self._attr_is_locked = True async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" + self.calls_unlock(kwargs) self._attr_is_unlocking = False self._attr_is_locked = False @@ -232,6 +236,50 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: assert not lock.is_locked +async def test_lock_with_illegal_code(hass: HomeAssistant) -> None: + """Test lock entity with default code that does not match the code format.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + ) + lock.hass = hass + + with pytest.raises(ValueError): + await _async_open( + lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "123456"}) + ) + with pytest.raises(ValueError): + await _async_lock( + lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "123456"}) + ) + with pytest.raises(ValueError): + await _async_unlock( + lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "123456"}) + ) + + +async def test_lock_with_no_code(hass: HomeAssistant) -> None: + """Test lock entity with default code that does not match the code format.""" + lock = MockLockEntity( + supported_features=LockEntityFeature.OPEN, + ) + lock.hass = hass + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + lock.calls_open.assert_called_with({}) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + lock.calls_lock.assert_called_with({}) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + lock.calls_unlock.assert_called_with({}) + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + lock.calls_open.assert_called_with({}) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + lock.calls_lock.assert_called_with({}) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + lock.calls_unlock.assert_called_with({}) + + async def test_lock_with_default_code(hass: HomeAssistant) -> None: """Test lock entity with default code.""" lock = MockLockEntity( @@ -245,5 +293,52 @@ async def test_lock_with_default_code(hass: HomeAssistant) -> None: assert lock._lock_option_default_code == "1234" await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) + + +async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None: + """Test lock entity with provided code when default code is set.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + lock_option_default_code="1234", + ) + lock.hass = hass + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "4321"})) + lock.calls_open.assert_called_with({ATTR_CODE: "4321"}) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "4321"})) + lock.calls_lock.assert_called_with({ATTR_CODE: "4321"}) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "4321"})) + lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"}) + + +async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None: + """Test lock entity with default code that does not match the code format.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + lock_option_default_code="123456", + ) + lock.hass = hass + + assert lock.state_attributes == {"code_format": r"^\d{4}$"} + assert lock._lock_option_default_code == "123456" + + with pytest.raises(ValueError): + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + with pytest.raises(ValueError): + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + with pytest.raises(ValueError): + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) From 9d3f37472824c43a6b12c6b13e8c8a19c30dcd56 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 20 Nov 2023 22:39:22 +0100 Subject: [PATCH 619/982] Add `todo.remove_completed_items` service call (#104035) * Extend `remove_item` service by status * update services.yaml * Create own service * add tests * Update tests/components/todo/test_init.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/todo/__init__.py | 17 +++++ homeassistant/components/todo/services.yaml | 2 + homeassistant/components/todo/strings.json | 4 + tests/components/todo/test_init.py | 84 ++++++++++++++++++--- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 1bd050b0872..4b76ee5a689 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -90,6 +90,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _async_get_todo_items, supports_response=SupportsResponse.ONLY, ) + component.async_register_entity_service( + "remove_completed_items", + {}, + _async_remove_completed_items, + required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], + ) await component.async_setup(config) return True @@ -284,3 +290,14 @@ async def _async_get_todo_items( if not (statuses := call.data.get("status")) or item.status in statuses ] } + + +async def _async_remove_completed_items(entity: TodoListEntity, _: ServiceCall) -> None: + """Remove all completed items from the To-do list.""" + uids = [ + item.uid + for item in entity.todo_items or () + if item.status == TodoItemStatus.COMPLETED and item.uid + ] + if uids: + await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 2030229f8d9..5474efefbdf 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -60,3 +60,5 @@ remove_item: required: true selector: text: + +remove_completed_items: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 30058b28c56..a651a161763 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -44,6 +44,10 @@ } } }, + "remove_completed_items": { + "name": "Remove all completed to-do list items", + "description": "Remove all to-do list items that have been completed." + }, "remove_item": { "name": "Remove a to-do list item", "description": "Remove an existing to-do list item by its name.", diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index e6d4a8d1d06..907ee695ed1 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -52,13 +52,22 @@ class MockFlow(ConfigFlow): class MockTodoListEntity(TodoListEntity): """Test todo list entity.""" - def __init__(self) -> None: + def __init__(self, items: list[TodoItem] | None = None) -> None: """Initialize entity.""" - self.items: list[TodoItem] = [] + self._attr_todo_items = items or [] + + @property + def items(self) -> list[TodoItem]: + """Return the items in the To-do list.""" + return self._attr_todo_items async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" - self.items.append(item) + self._attr_todo_items.append(item) + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item in the To-do list.""" + self._attr_todo_items = [item for item in self.items if item.uid not in uids] @pytest.fixture(autouse=True) @@ -130,7 +139,12 @@ async def create_mock_platform( @pytest.fixture(name="test_entity") def mock_test_entity() -> TodoListEntity: """Fixture that creates a test TodoList entity with mock service calls.""" - entity1 = TodoListEntity() + entity1 = MockTodoListEntity( + [ + TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + ] + ) entity1.entity_id = "todo.entity1" entity1._attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM @@ -138,13 +152,9 @@ def mock_test_entity() -> TodoListEntity: | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.MOVE_TODO_ITEM ) - entity1._attr_todo_items = [ - TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), - TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), - ] - entity1.async_create_todo_item = AsyncMock() + entity1.async_create_todo_item = AsyncMock(wraps=entity1.async_create_todo_item) entity1.async_update_todo_item = AsyncMock() - entity1.async_delete_todo_items = AsyncMock() + entity1.async_delete_todo_items = AsyncMock(wraps=entity1.async_delete_todo_items) entity1.async_move_todo_item = AsyncMock() return entity1 @@ -763,12 +773,16 @@ async def test_move_todo_item_service_invalid_input( "rename": "Updated item", }, ), + ( + "remove_completed_items", + None, + ), ], ) async def test_unsupported_service( hass: HomeAssistant, service_name: str, - payload: dict[str, Any], + payload: dict[str, Any] | None, ) -> None: """Test a To-do list that does not support features.""" @@ -879,3 +893,51 @@ async def test_add_item_intent( todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, ) + + +async def test_remove_completed_items_service( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test remove completed todo items service.""" + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "remove_completed_items", + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_delete_todo_items.call_args + assert args + assert args.kwargs.get("uids") == ["2"] + + test_entity.async_delete_todo_items.reset_mock() + + # calling service multiple times will not call the entity method + await hass.services.async_call( + DOMAIN, + "remove_completed_items", + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + test_entity.async_delete_todo_items.assert_not_called() + + +async def test_remove_completed_items_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test removing all completed item from a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + test_entity.async_delete_todo_items.side_effect = HomeAssistantError("Ooops") + with pytest.raises(HomeAssistantError, match="Ooops"): + await hass.services.async_call( + DOMAIN, + "remove_completed_items", + target={"entity_id": "todo.entity1"}, + blocking=True, + ) From 6f82c2e2301c5cd891bfc72aa85bb2190326541e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 21 Nov 2023 07:19:04 +0100 Subject: [PATCH 620/982] Bump pyOverkiz to 1.13.3 (#104280) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index cc9a410392a..dd78ec78f00 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.2"], + "requirements": ["pyoverkiz==1.13.3"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 034c4ba3755..7da2a057e91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1949,7 +1949,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.2 +pyoverkiz==1.13.3 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25becb876b3..3f16ddc189c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1469,7 +1469,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.2 +pyoverkiz==1.13.3 # homeassistant.components.openweathermap pyowm==3.2.0 From afc664f83f515e38421c8bcf08e4e4336908aeee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Nov 2023 07:44:34 +0100 Subject: [PATCH 621/982] Update adguardhome to 0.6.3 (#104253) --- homeassistant/components/adguard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json index 24e1283e9df..52add51a663 100644 --- a/homeassistant/components/adguard/manifest.json +++ b/homeassistant/components/adguard/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["adguardhome"], - "requirements": ["adguardhome==0.6.2"] + "requirements": ["adguardhome==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7da2a057e91..f12d6987d54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -155,7 +155,7 @@ adb-shell[async]==0.4.4 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.6.2 +adguardhome==0.6.3 # homeassistant.components.advantage_air advantage-air==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f16ddc189c..bce1a49720d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -134,7 +134,7 @@ adb-shell[async]==0.4.4 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.6.2 +adguardhome==0.6.3 # homeassistant.components.advantage_air advantage-air==0.4.4 From af6f451cc0e181b1af062cee3236584deb5c4855 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Nov 2023 07:48:05 +0100 Subject: [PATCH 622/982] Bump aioesphomeapi to 18.5.5 (#104285) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e3ac4ac5600..910d5dd00bd 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.5.4", + "aioesphomeapi==18.5.5", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index f12d6987d54..1f2a180ea37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.4 +aioesphomeapi==18.5.5 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bce1a49720d..823319e8581 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.4 +aioesphomeapi==18.5.5 # homeassistant.components.flo aioflo==2021.11.0 From d4ca9843e2cf53fc365589e1a379ce6c77e92b2e Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Tue, 21 Nov 2023 07:50:00 +0100 Subject: [PATCH 623/982] Bump bimmer_connected to 0.14.3 (#104282) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index b5652694120..911a998371e 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.2"] + "requirements": ["bimmer-connected==0.14.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1f2a180ea37..c4cf445b6f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -529,7 +529,7 @@ beautifulsoup4==4.12.2 bellows==0.36.8 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.2 +bimmer-connected==0.14.3 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 823319e8581..a3d2404fc1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ beautifulsoup4==4.12.2 bellows==0.36.8 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.2 +bimmer-connected==0.14.3 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 From 645f916cf4328cf8c38bf6ef00948fed824925ab Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Nov 2023 07:51:41 +0100 Subject: [PATCH 624/982] Remove support for deprecated light attributes from light scenes (#104254) --- .../components/light/reproduce_state.py | 37 +-------- .../components/light/test_reproduce_state.py | 80 ++----------------- 2 files changed, 8 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index f055f02ebda..54fcd01843c 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -17,15 +17,10 @@ from homeassistant.core import Context, HomeAssistant, State from . import ( ATTR_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT, ATTR_COLOR_MODE, - ATTR_COLOR_NAME, ATTR_COLOR_TEMP, ATTR_EFFECT, - ATTR_FLASH, ATTR_HS_COLOR, - ATTR_KELVIN, - ATTR_PROFILE, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -40,13 +35,7 @@ _LOGGER = logging.getLogger(__name__) VALID_STATES = {STATE_ON, STATE_OFF} -ATTR_GROUP = [ - ATTR_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT, - ATTR_EFFECT, - ATTR_FLASH, - ATTR_TRANSITION, -] +ATTR_GROUP = [ATTR_BRIGHTNESS, ATTR_EFFECT] COLOR_GROUP = [ ATTR_HS_COLOR, @@ -55,10 +44,6 @@ COLOR_GROUP = [ ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_XY_COLOR, - # The following color attributes are deprecated - ATTR_PROFILE, - ATTR_COLOR_NAME, - ATTR_KELVIN, ] @@ -79,21 +64,6 @@ COLOR_MODE_TO_ATTRIBUTE = { ColorMode.XY: ColorModeAttr(ATTR_XY_COLOR, ATTR_XY_COLOR), } -DEPRECATED_GROUP = [ - ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_NAME, - ATTR_FLASH, - ATTR_KELVIN, - ATTR_PROFILE, - ATTR_TRANSITION, -] - -DEPRECATION_WARNING = ( - "The use of other attributes than device state attributes is deprecated and will be" - " removed in a future release. Invalid attributes are %s. Read the logs for further" - " details: https://www.home-assistant.io/integrations/scene/" -) - def _color_mode_same(cur_state: State, state: State) -> bool: """Test if color_mode is same.""" @@ -124,11 +94,6 @@ async def _async_reproduce_state( ) return - # Warn if deprecated attributes are used - deprecated_attrs = [attr for attr in state.attributes if attr in DEPRECATED_GROUP] - if deprecated_attrs: - _LOGGER.warning(DEPRECATION_WARNING, deprecated_attrs) - # Return if we are already at the right state. if ( cur_state.state == state.state diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index 816bde430e7..65b83aa0269 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -2,35 +2,24 @@ import pytest from homeassistant.components import light -from homeassistant.components.light.reproduce_state import DEPRECATION_WARNING from homeassistant.core import HomeAssistant, State from homeassistant.helpers.state import async_reproduce_state from tests.common import async_mock_service VALID_BRIGHTNESS = {"brightness": 180} -VALID_FLASH = {"flash": "short"} VALID_EFFECT = {"effect": "random"} -VALID_TRANSITION = {"transition": 15} -VALID_COLOR_NAME = {"color_name": "red"} VALID_COLOR_TEMP = {"color_temp": 240} VALID_HS_COLOR = {"hs_color": (345, 75)} -VALID_KELVIN = {"kelvin": 4000} -VALID_PROFILE = {"profile": "relax"} VALID_RGB_COLOR = {"rgb_color": (255, 63, 111)} VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} VALID_RGBWW_COLOR = {"rgbww_color": (255, 63, 111, 10, 20)} VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} NONE_BRIGHTNESS = {"brightness": None} -NONE_FLASH = {"flash": None} NONE_EFFECT = {"effect": None} -NONE_TRANSITION = {"transition": None} -NONE_COLOR_NAME = {"color_name": None} NONE_COLOR_TEMP = {"color_temp": None} NONE_HS_COLOR = {"hs_color": None} -NONE_KELVIN = {"kelvin": None} -NONE_PROFILE = {"profile": None} NONE_RGB_COLOR = {"rgb_color": None} NONE_RGBW_COLOR = {"rgbw_color": None} NONE_RGBWW_COLOR = {"rgbww_color": None} @@ -43,14 +32,9 @@ async def test_reproducing_states( """Test reproducing Light states.""" hass.states.async_set("light.entity_off", "off", {}) hass.states.async_set("light.entity_bright", "on", VALID_BRIGHTNESS) - hass.states.async_set("light.entity_flash", "on", VALID_FLASH) hass.states.async_set("light.entity_effect", "on", VALID_EFFECT) - hass.states.async_set("light.entity_trans", "on", VALID_TRANSITION) - hass.states.async_set("light.entity_name", "on", VALID_COLOR_NAME) hass.states.async_set("light.entity_temp", "on", VALID_COLOR_TEMP) hass.states.async_set("light.entity_hs", "on", VALID_HS_COLOR) - hass.states.async_set("light.entity_kelvin", "on", VALID_KELVIN) - hass.states.async_set("light.entity_profile", "on", VALID_PROFILE) hass.states.async_set("light.entity_rgb", "on", VALID_RGB_COLOR) hass.states.async_set("light.entity_xy", "on", VALID_XY_COLOR) @@ -63,14 +47,9 @@ async def test_reproducing_states( [ State("light.entity_off", "off"), State("light.entity_bright", "on", VALID_BRIGHTNESS), - State("light.entity_flash", "on", VALID_FLASH), State("light.entity_effect", "on", VALID_EFFECT), - State("light.entity_trans", "on", VALID_TRANSITION), - State("light.entity_name", "on", VALID_COLOR_NAME), State("light.entity_temp", "on", VALID_COLOR_TEMP), State("light.entity_hs", "on", VALID_HS_COLOR), - State("light.entity_kelvin", "on", VALID_KELVIN), - State("light.entity_profile", "on", VALID_PROFILE), State("light.entity_rgb", "on", VALID_RGB_COLOR), State("light.entity_xy", "on", VALID_XY_COLOR), ], @@ -92,20 +71,15 @@ async def test_reproducing_states( [ State("light.entity_xy", "off"), State("light.entity_off", "on", VALID_BRIGHTNESS), - State("light.entity_bright", "on", VALID_FLASH), - State("light.entity_flash", "on", VALID_EFFECT), - State("light.entity_effect", "on", VALID_TRANSITION), - State("light.entity_trans", "on", VALID_COLOR_NAME), - State("light.entity_name", "on", VALID_COLOR_TEMP), + State("light.entity_bright", "on", VALID_EFFECT), + State("light.entity_effect", "on", VALID_COLOR_TEMP), State("light.entity_temp", "on", VALID_HS_COLOR), - State("light.entity_hs", "on", VALID_KELVIN), - State("light.entity_kelvin", "on", VALID_PROFILE), - State("light.entity_profile", "on", VALID_RGB_COLOR), + State("light.entity_hs", "on", VALID_RGB_COLOR), State("light.entity_rgb", "on", VALID_XY_COLOR), ], ) - assert len(turn_on_calls) == 11 + assert len(turn_on_calls) == 6 expected_calls = [] @@ -113,42 +87,22 @@ async def test_reproducing_states( expected_off["entity_id"] = "light.entity_off" expected_calls.append(expected_off) - expected_bright = dict(VALID_FLASH) + expected_bright = dict(VALID_EFFECT) expected_bright["entity_id"] = "light.entity_bright" expected_calls.append(expected_bright) - expected_flash = dict(VALID_EFFECT) - expected_flash["entity_id"] = "light.entity_flash" - expected_calls.append(expected_flash) - - expected_effect = dict(VALID_TRANSITION) + expected_effect = dict(VALID_COLOR_TEMP) expected_effect["entity_id"] = "light.entity_effect" expected_calls.append(expected_effect) - expected_trans = dict(VALID_COLOR_NAME) - expected_trans["entity_id"] = "light.entity_trans" - expected_calls.append(expected_trans) - - expected_name = dict(VALID_COLOR_TEMP) - expected_name["entity_id"] = "light.entity_name" - expected_calls.append(expected_name) - expected_temp = dict(VALID_HS_COLOR) expected_temp["entity_id"] = "light.entity_temp" expected_calls.append(expected_temp) - expected_hs = dict(VALID_KELVIN) + expected_hs = dict(VALID_RGB_COLOR) expected_hs["entity_id"] = "light.entity_hs" expected_calls.append(expected_hs) - expected_kelvin = dict(VALID_PROFILE) - expected_kelvin["entity_id"] = "light.entity_kelvin" - expected_calls.append(expected_kelvin) - - expected_profile = dict(VALID_RGB_COLOR) - expected_profile["entity_id"] = "light.entity_profile" - expected_calls.append(expected_profile) - expected_rgb = dict(VALID_XY_COLOR) expected_rgb["entity_id"] = "light.entity_rgb" expected_calls.append(expected_rgb) @@ -191,10 +145,8 @@ async def test_filter_color_modes( """Test filtering of parameters according to color mode.""" hass.states.async_set("light.entity", "off", {}) all_colors = { - **VALID_COLOR_NAME, **VALID_COLOR_TEMP, **VALID_HS_COLOR, - **VALID_KELVIN, **VALID_RGB_COLOR, **VALID_RGBW_COLOR, **VALID_RGBWW_COLOR, @@ -240,31 +192,13 @@ async def test_filter_color_modes( assert len(turn_on_calls) == 1 -async def test_deprecation_warning( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecation warning.""" - hass.states.async_set("light.entity_off", "off", {}) - turn_on_calls = async_mock_service(hass, "light", "turn_on") - await async_reproduce_state( - hass, [State("light.entity_off", "on", {"brightness_pct": 80})] - ) - assert len(turn_on_calls) == 1 - assert DEPRECATION_WARNING % ["brightness_pct"] in caplog.text - - @pytest.mark.parametrize( "saved_state", ( NONE_BRIGHTNESS, - NONE_FLASH, NONE_EFFECT, - NONE_TRANSITION, - NONE_COLOR_NAME, NONE_COLOR_TEMP, NONE_HS_COLOR, - NONE_KELVIN, - NONE_PROFILE, NONE_RGB_COLOR, NONE_RGBW_COLOR, NONE_RGBWW_COLOR, From dad8545138eb35987a020084ec9e25ef32d66b81 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 21 Nov 2023 07:56:48 +0100 Subject: [PATCH 625/982] Bump easyenergy lib to v1.0.0 (#104289) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 5755a1b3dbe..6fa177c7221 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.3.0"] + "requirements": ["easyenergy==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c4cf445b6f8..6e1737f334b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -724,7 +724,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.3.0 +easyenergy==1.0.0 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3d2404fc1c..5cb6fd7b2ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -590,7 +590,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.3.0 +easyenergy==1.0.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 From 3e1c12507ee24ecb0f0d365955d6f10911c39adb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 20 Nov 2023 22:57:31 -0800 Subject: [PATCH 626/982] Bump pyrainbird to 4.0.1 (#104293) --- homeassistant/components/rainbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 07a0bc0a5f6..b8cb86264f2 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rainbird", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==4.0.0"] + "requirements": ["pyrainbird==4.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6e1737f334b..21f94c91600 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1994,7 +1994,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==4.0.0 +pyrainbird==4.0.1 # homeassistant.components.recswitch pyrecswitch==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5cb6fd7b2ea..b03cb6343db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1505,7 +1505,7 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.rainbird -pyrainbird==4.0.0 +pyrainbird==4.0.1 # homeassistant.components.risco pyrisco==0.5.8 From 29ac3a8f66ef98ec70165746ddf2db47f542453d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Nov 2023 07:58:22 +0100 Subject: [PATCH 627/982] Fix memory leak in ESPHome disconnect callbacks (#104149) --- .../components/esphome/bluetooth/client.py | 9 ++++++--- homeassistant/components/esphome/entry_data.py | 18 +++++++++++++++++- homeassistant/components/esphome/manager.py | 16 +++++----------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 6cf1d6b5381..22d4392ce31 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -136,7 +136,7 @@ class ESPHomeClientData: api_version: APIVersion title: str scanner: ESPHomeScanner | None - disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set) class ESPHomeClient(BaseBleakClient): @@ -215,6 +215,7 @@ class ESPHomeClient(BaseBleakClient): if not future.done(): future.set_result(None) self._disconnected_futures.clear() + self._disconnect_callbacks.discard(self._async_esp_disconnected) self._unsubscribe_connection_state() def _async_ble_device_disconnected(self) -> None: @@ -228,7 +229,9 @@ class ESPHomeClient(BaseBleakClient): def _async_esp_disconnected(self) -> None: """Handle the esp32 client disconnecting from us.""" _LOGGER.debug("%s: ESP device disconnected", self._description) - self._disconnect_callbacks.remove(self._async_esp_disconnected) + # Calling _async_ble_device_disconnected calls + # _async_disconnected_cleanup which will also remove + # the disconnect callbacks self._async_ble_device_disconnected() def _async_call_bleak_disconnected_callback(self) -> None: @@ -289,7 +292,7 @@ class ESPHomeClient(BaseBleakClient): "%s: connected, registering for disconnected callbacks", self._description, ) - self._disconnect_callbacks.append(self._async_esp_disconnected) + self._disconnect_callbacks.add(self._async_esp_disconnected) connected_future.set_result(connected) @api_error_as_bleak_error diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index e53200c2e90..89629a65ea5 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -107,7 +107,7 @@ class RuntimeEntryData: bluetooth_device: ESPHomeBluetoothDevice | None = None api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) - disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set) state_subscriptions: dict[ tuple[type[EntityState], int], Callable[[], None] ] = field(default_factory=dict) @@ -427,3 +427,19 @@ class RuntimeEntryData: if self.original_options == entry.options: return hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + + @callback + def async_on_disconnect(self) -> None: + """Call when the entry has been disconnected. + + Safe to call multiple times. + """ + self.available = False + # Make a copy since calling the disconnect callbacks + # may also try to discard/remove themselves. + for disconnect_cb in self.disconnect_callbacks.copy(): + disconnect_cb() + # Make sure to clear the set to give up the reference + # to it and make sure all the callbacks can be GC'd. + self.disconnect_callbacks.clear() + self.disconnect_callbacks = set() diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 62ef1d43a5f..8282940a71d 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -295,7 +295,7 @@ class ESPHomeManager: event.data["entity_id"], attribute, new_state ) - self.entry_data.disconnect_callbacks.append( + self.entry_data.disconnect_callbacks.add( async_track_state_change_event( hass, [entity_id], send_home_assistant_state_event ) @@ -440,7 +440,7 @@ class ESPHomeManager: reconnect_logic.name = device_info.name if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): - entry_data.disconnect_callbacks.append( + entry_data.disconnect_callbacks.add( await async_connect_scanner( hass, entry, cli, entry_data, self.domain_data.bluetooth_cache ) @@ -462,7 +462,7 @@ class ESPHomeManager: ) if device_info.voice_assistant_version: - entry_data.disconnect_callbacks.append( + entry_data.disconnect_callbacks.add( await cli.subscribe_voice_assistant( self._handle_pipeline_start, self._handle_pipeline_stop, @@ -490,10 +490,7 @@ class ESPHomeManager: host, expected_disconnect, ) - for disconnect_cb in entry_data.disconnect_callbacks: - disconnect_cb() - entry_data.disconnect_callbacks = [] - entry_data.available = False + entry_data.async_on_disconnect() entry_data.expected_disconnect = expected_disconnect # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects @@ -758,10 +755,7 @@ async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEn """Cleanup the esphome client if it exists.""" domain_data = DomainData.get(hass) data = domain_data.pop_entry_data(entry) - data.available = False - for disconnect_cb in data.disconnect_callbacks: - disconnect_cb() - data.disconnect_callbacks = [] + data.async_on_disconnect() for cleanup_callback in data.cleanup_callbacks: cleanup_callback() await data.async_cleanup() From 5c72d3c2d81da21ddb9a7ac42f9b04f371134dfd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Nov 2023 07:59:39 +0100 Subject: [PATCH 628/982] Restore removed guard for non-string inputs in Alexa (#104263) --- homeassistant/components/alexa/capabilities.py | 6 ++++-- tests/components/alexa/test_capabilities.py | 3 ++- tests/components/alexa/test_smart_home.py | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index cde90e127f3..0856c39946b 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -857,16 +857,18 @@ class AlexaInputController(AlexaCapability): def inputs(self) -> list[dict[str, str]] | None: """Return the list of valid supported inputs.""" - source_list: list[str] = self.entity.attributes.get( + source_list: list[Any] = self.entity.attributes.get( media_player.ATTR_INPUT_SOURCE_LIST, [] ) return AlexaInputController.get_valid_inputs(source_list) @staticmethod - def get_valid_inputs(source_list: list[str]) -> list[dict[str, str]]: + def get_valid_inputs(source_list: list[Any]) -> list[dict[str, str]]: """Return list of supported inputs.""" input_list: list[dict[str, str]] = [] for source in source_list: + if not isinstance(source, str): + continue formatted_source = ( source.lower().replace("-", "").replace("_", "").replace(" ", "") ) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index a6be57e9ed5..11e39c40cb1 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -183,7 +183,7 @@ async def test_api_increase_color_temp( ("domain", "payload", "source_list", "idx"), [ ("media_player", "GAME CONSOLE", ["tv", "game console", 10000], 1), - ("media_player", "SATELLITE TV", ["satellite-tv", "game console"], 0), + ("media_player", "SATELLITE TV", ["satellite-tv", "game console", None], 0), ("media_player", "SATELLITE TV", ["satellite_tv", "game console"], 0), ("media_player", "BAD DEVICE", ["satellite_tv", "game console"], None), ], @@ -864,6 +864,7 @@ async def test_report_playback_state(hass: HomeAssistant) -> None: | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP, "volume_level": 0.75, + "source_list": [None], }, ) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index e24ec4c950b..7a1abe96110 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1439,6 +1439,8 @@ async def test_media_player_inputs(hass: HomeAssistant) -> None: "aux", "input 1", "tv", + 0, + None, ], }, ) From f359b33f2e628bc201cab9c51a4ce30e13a0bc91 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 21 Nov 2023 08:19:33 +0100 Subject: [PATCH 629/982] Bump energyzero lib to v1.0.0 (#104288) --- homeassistant/components/energyzero/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 8e2b8aba894..9ef99173ffb 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==0.5.0"] + "requirements": ["energyzero==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 21f94c91600..171b5cca6af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -757,7 +757,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.5.0 +energyzero==1.0.0 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b03cb6343db..0ed5bdefd4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -611,7 +611,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.5.0 +energyzero==1.0.0 # homeassistant.components.enocean enocean==0.50 From 6f4124341042ead186f3a9ee299bca915020ccfa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Nov 2023 08:25:46 +0100 Subject: [PATCH 630/982] Change confusing parameter naming in reload helper (#104257) --- homeassistant/helpers/reload.py | 50 ++++++++++++++++----------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 75529476dd2..6e719cdac24 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -26,7 +26,7 @@ PLATFORM_RESET_LOCK = "lock_async_reset_platform_{}" async def async_reload_integration_platforms( - hass: HomeAssistant, integration_name: str, integration_platforms: Iterable[str] + hass: HomeAssistant, integration_domain: str, platform_domains: Iterable[str] ) -> None: """Reload an integration's platforms. @@ -44,10 +44,8 @@ async def async_reload_integration_platforms( return tasks = [ - _resetup_platform( - hass, integration_name, integration_platform, unprocessed_conf - ) - for integration_platform in integration_platforms + _resetup_platform(hass, integration_domain, platform_domain, unprocessed_conf) + for platform_domain in platform_domains ] await asyncio.gather(*tasks) @@ -55,27 +53,27 @@ async def async_reload_integration_platforms( async def _resetup_platform( hass: HomeAssistant, - integration_name: str, - integration_platform: str, - unprocessed_conf: ConfigType, + integration_domain: str, + platform_domain: str, + unprocessed_config: ConfigType, ) -> None: """Resetup a platform.""" - integration = await async_get_integration(hass, integration_platform) + integration = await async_get_integration(hass, platform_domain) conf = await conf_util.async_process_component_config( - hass, unprocessed_conf, integration + hass, unprocessed_config, integration ) if not conf: return - root_config: dict[str, list[ConfigType]] = {integration_platform: []} + root_config: dict[str, list[ConfigType]] = {platform_domain: []} # Extract only the config for template, ignore the rest. - for p_type, p_config in config_per_platform(conf, integration_platform): - if p_type != integration_name: + for p_type, p_config in config_per_platform(conf, platform_domain): + if p_type != integration_domain: continue - root_config[integration_platform].append(p_config) + root_config[platform_domain].append(p_config) component = integration.get_component() @@ -83,47 +81,47 @@ async def _resetup_platform( # If the integration has its own way to reset # use this method. async with hass.data.setdefault( - PLATFORM_RESET_LOCK.format(integration_platform), asyncio.Lock() + PLATFORM_RESET_LOCK.format(platform_domain), asyncio.Lock() ): - await component.async_reset_platform(hass, integration_name) + await component.async_reset_platform(hass, integration_domain) await component.async_setup(hass, root_config) return # If it's an entity platform, we use the entity_platform # async_reset method platform = async_get_platform_without_config_entry( - hass, integration_name, integration_platform + hass, integration_domain, platform_domain ) if platform: - await _async_reconfig_platform(platform, root_config[integration_platform]) + await _async_reconfig_platform(platform, root_config[platform_domain]) return - if not root_config[integration_platform]: + if not root_config[platform_domain]: # No config for this platform # and it's not loaded. Nothing to do. return await _async_setup_platform( - hass, integration_name, integration_platform, root_config[integration_platform] + hass, integration_domain, platform_domain, root_config[platform_domain] ) async def _async_setup_platform( hass: HomeAssistant, - integration_name: str, - integration_platform: str, + integration_domain: str, + platform_domain: str, platform_configs: list[dict[str, Any]], ) -> None: """Platform for the first time when new configuration is added.""" - if integration_platform not in hass.data: + if platform_domain not in hass.data: await async_setup_component( - hass, integration_platform, {integration_platform: platform_configs} + hass, platform_domain, {platform_domain: platform_configs} ) return - entity_component: EntityComponent[Entity] = hass.data[integration_platform] + entity_component: EntityComponent[Entity] = hass.data[platform_domain] tasks = [ - entity_component.async_setup_platform(integration_name, p_config) + entity_component.async_setup_platform(integration_domain, p_config) for p_config in platform_configs ] await asyncio.gather(*tasks) From 5805601a839987d406f1cc16113ab9b5007fa2d1 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 21 Nov 2023 09:53:02 +0100 Subject: [PATCH 631/982] Set unique_id by base entity in ViCare integration (#104277) * set unique_id in ViCareEntity * remove individual unique_id functions * remove description * remove individual _attr_unique_id * fix return types --- .../components/vicare/binary_sensor.py | 18 +++--------------- homeassistant/components/vicare/button.py | 14 +------------- homeassistant/components/vicare/climate.py | 4 +--- homeassistant/components/vicare/entity.py | 16 +++++++++++++++- homeassistant/components/vicare/sensor.py | 18 +++--------------- .../components/vicare/water_heater.py | 4 +--- 6 files changed, 24 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 4e3d8d05f97..9e9f133b730 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -198,28 +198,16 @@ class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): self, name, api, device_config, description: ViCareBinarySensorEntityDescription ) -> None: """Initialize the sensor.""" - super().__init__(device_config) + super().__init__(device_config, api, description.key) self.entity_description = description self._attr_name = name - self._api = api - self._device_config = device_config @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._attr_is_on is not None - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - tmp_id = ( - f"{self._device_config.getConfig().serial}-{self.entity_description.key}" - ) - if hasattr(self._api, "id"): - return f"{tmp_id}-{self._api.id}" - return tmp_id - - def update(self): + def update(self) -> None: """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 2516446a94e..5ea6cf5edae 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -99,10 +99,8 @@ class ViCareButton(ViCareEntity, ButtonEntity): self, name, api, device_config, description: ViCareButtonEntityDescription ) -> None: """Initialize the button.""" - super().__init__(device_config) + super().__init__(device_config, api, description.key) self.entity_description = description - self._device_config = device_config - self._api = api def press(self) -> None: """Handle the button press.""" @@ -117,13 +115,3 @@ class ViCareButton(ViCareEntity, ButtonEntity): _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - tmp_id = ( - f"{self._device_config.getConfig().serial}-{self.entity_description.key}" - ) - if hasattr(self._api, "id"): - return f"{tmp_id}-{self._api.id}" - return tmp_id diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index d306cc6604d..0c145e5e5a8 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -150,13 +150,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): def __init__(self, name, api, circuit, device_config) -> None: """Initialize the climate device.""" - super().__init__(device_config) + super().__init__(device_config, api, circuit.id) self._attr_name = name - self._api = api self._circuit = circuit self._attributes: dict[str, Any] = {} self._current_program = None - self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 089f9c062b8..af35c7bf8dd 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -1,4 +1,7 @@ """Entities for the ViCare integration.""" +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -10,8 +13,19 @@ class ViCareEntity(Entity): _attr_has_entity_name = True - def __init__(self, device_config) -> None: + def __init__( + self, + device_config: PyViCareDeviceConfig, + device: PyViCareDevice, + unique_id_suffix: str, + ) -> None: """Initialize the entity.""" + self._api = device + + self._attr_unique_id = f"{device_config.getConfig().serial}-{unique_id_suffix}" + # valid for compressors, circuits, burners (HeatingDeviceWithComponent) + if hasattr(device, "id"): + self._attr_unique_id += f"-{device.id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_config.getConfig().serial)}, diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 99f1eef80b9..ae83459ddda 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -688,28 +688,16 @@ class ViCareSensor(ViCareEntity, SensorEntity): self, name, api, device_config, description: ViCareSensorEntityDescription ) -> None: """Initialize the sensor.""" - super().__init__(device_config) + super().__init__(device_config, api, description.key) self.entity_description = description self._attr_name = name - self._api = api - self._device_config = device_config @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._attr_native_value is not None - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - tmp_id = ( - f"{self._device_config.getConfig().serial}-{self.entity_description.key}" - ) - if hasattr(self._api, "id"): - return f"{tmp_id}-{self._api.id}" - return tmp_id - - def update(self): + def update(self) -> None: """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index db8a959f4ae..0e927a24650 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -101,13 +101,11 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity): def __init__(self, name, api, circuit, device_config) -> None: """Initialize the DHW water_heater device.""" - super().__init__(device_config) + super().__init__(device_config, api, circuit.id) self._attr_name = name - self._api = api self._circuit = circuit self._attributes: dict[str, Any] = {} self._current_mode = None - self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" From eb5c7a3e766b828338019d06c8b59401bdc4e962 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 21 Nov 2023 09:59:34 +0100 Subject: [PATCH 632/982] Add Fastdotcom config flow (#98686) * Adding config flow and tests * Removing update and adding to integrations.json * Updating hassfest * Removing comments * Removing unique ID * Putting the setup_platform out of order * Adding feedback on issues and importing * Removing uniqueID (again) * Adjusting unload and typo * Updating manifest properly * Minor patching * Removing hass.data.setdefault(DOMAIN, {}) * Moving load_platform to __init__.py * Update homeassistant/components/fastdotcom/config_flow.py Co-authored-by: G Johansson * Update homeassistant/components/fastdotcom/strings.json Co-authored-by: G Johansson * Update homeassistant/components/fastdotcom/__init__.py Co-authored-by: G Johansson * Update homeassistant/components/fastdotcom/config_flow.py Co-authored-by: G Johansson * Adding an unload function for the timer * Adding issue on setup platform in sensor * Update homeassistant/components/fastdotcom/config_flow.py Co-authored-by: G Johansson * Removing platform * Fixing strings.json * Fine-tuning * Putting back last_state --------- Co-authored-by: G Johansson --- .coveragerc | 3 +- CODEOWNERS | 3 +- .../components/fastdotcom/__init__.py | 60 +++++++++------ .../components/fastdotcom/config_flow.py | 50 +++++++++++++ homeassistant/components/fastdotcom/const.py | 15 ++++ .../components/fastdotcom/manifest.json | 3 +- homeassistant/components/fastdotcom/sensor.py | 13 ++-- .../components/fastdotcom/strings.json | 10 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/fastdotcom/__init__.py | 1 + .../components/fastdotcom/test_config_flow.py | 74 +++++++++++++++++++ 13 files changed, 206 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/fastdotcom/config_flow.py create mode 100644 homeassistant/components/fastdotcom/const.py create mode 100644 tests/components/fastdotcom/__init__.py create mode 100644 tests/components/fastdotcom/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index a05cc48785e..cdb5bdd07e4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -368,7 +368,8 @@ omit = homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/faa_delays/coordinator.py homeassistant/components/familyhub/camera.py - homeassistant/components/fastdotcom/* + homeassistant/components/fastdotcom/sensor.py + homeassistant/components/fastdotcom/__init__.py homeassistant/components/ffmpeg/camera.py homeassistant/components/fibaro/__init__.py homeassistant/components/fibaro/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 25f8702ab5a..72c58942abc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -373,7 +373,8 @@ build.json @home-assistant/supervisor /tests/components/faa_delays/ @ntilley905 /homeassistant/components/fan/ @home-assistant/core /tests/components/fan/ @home-assistant/core -/homeassistant/components/fastdotcom/ @rohankapoorcom +/homeassistant/components/fastdotcom/ @rohankapoorcom @erwindouna +/tests/components/fastdotcom/ @rohankapoorcom @erwindouna /homeassistant/components/fibaro/ @rappenze /tests/components/fibaro/ @rappenze /homeassistant/components/file/ @fabaff diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 50e0cb04869..2fe5b3ccafc 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -8,23 +8,18 @@ from typing import Any from fastdotcom import fast_com import voluptuous as vol -from homeassistant.const import CONF_SCAN_INTERVAL, Platform +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -DOMAIN = "fastdotcom" -DATA_UPDATED = f"{DOMAIN}_data_updated" +from .const import CONF_MANUAL, DATA_UPDATED, DEFAULT_INTERVAL, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -CONF_MANUAL = "manual" - -DEFAULT_INTERVAL = timedelta(hours=1) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -40,38 +35,61 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup_platform(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Fast.com component. (deprecated).""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Fast.com component.""" - conf = config[DOMAIN] data = hass.data[DOMAIN] = SpeedtestData(hass) - if not conf[CONF_MANUAL]: - async_track_time_interval(hass, data.update, conf[CONF_SCAN_INTERVAL]) + entry.async_on_unload( + async_track_time_interval(hass, data.update, timedelta(hours=DEFAULT_INTERVAL)) + ) + # Run an initial update to get a starting state + await data.update() - def update(service_call: ServiceCall | None = None) -> None: + async def update(service_call: ServiceCall | None = None) -> None: """Service call to manually update the data.""" - data.update() + await data.update() hass.services.async_register(DOMAIN, "speedtest", update) - hass.async_create_task( - async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, config) + await hass.config_entries.async_forward_entry_setups( + entry, + PLATFORMS, ) return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Fast.com config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data.pop(DOMAIN) + return unload_ok + + class SpeedtestData: - """Get the latest data from fast.com.""" + """Get the latest data from Fast.com.""" def __init__(self, hass: HomeAssistant) -> None: """Initialize the data object.""" self.data: dict[str, Any] | None = None self._hass = hass - def update(self, now: datetime | None = None) -> None: + async def update(self, now: datetime | None = None) -> None: """Get the latest data from fast.com.""" - - _LOGGER.debug("Executing fast.com speedtest") - self.data = {"download": fast_com()} + _LOGGER.debug("Executing Fast.com speedtest") + fast_com_data = await self._hass.async_add_executor_job(fast_com) + self.data = {"download": fast_com_data} + _LOGGER.debug("Fast.com speedtest finished, with mbit/s: %s", fast_com_data) dispatcher_send(self._hass, DATA_UPDATED) diff --git a/homeassistant/components/fastdotcom/config_flow.py b/homeassistant/components/fastdotcom/config_flow.py new file mode 100644 index 00000000000..5ca35fd6802 --- /dev/null +++ b/homeassistant/components/fastdotcom/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for Fast.com integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DEFAULT_NAME, DOMAIN + + +class FastdotcomConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fast.com.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry(title=DEFAULT_NAME, data={}) + + return self.async_show_form(step_id="user") + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by configuration file.""" + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Fast.com", + }, + ) + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/fastdotcom/const.py b/homeassistant/components/fastdotcom/const.py new file mode 100644 index 00000000000..753825c4361 --- /dev/null +++ b/homeassistant/components/fastdotcom/const.py @@ -0,0 +1,15 @@ +"""Constants for the Fast.com integration.""" +import logging + +from homeassistant.const import Platform + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "fastdotcom" +DATA_UPDATED = f"{DOMAIN}_data_updated" + +CONF_MANUAL = "manual" + +DEFAULT_NAME = "Fast.com" +DEFAULT_INTERVAL = 1 +PLATFORMS: list[Platform] = [Platform.SENSOR] diff --git a/homeassistant/components/fastdotcom/manifest.json b/homeassistant/components/fastdotcom/manifest.json index 73db5c0bf11..02fd3ade205 100644 --- a/homeassistant/components/fastdotcom/manifest.json +++ b/homeassistant/components/fastdotcom/manifest.json @@ -1,7 +1,8 @@ { "domain": "fastdotcom", "name": "Fast.com", - "codeowners": ["@rohankapoorcom"], + "codeowners": ["@rohankapoorcom", "@erwindouna"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fastdotcom", "iot_class": "cloud_polling", "loggers": ["fastdotcom"], diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index b20b0213835..33ad4853404 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -8,29 +8,28 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_UPDATED, DOMAIN as FASTDOTCOM_DOMAIN +from .const import DATA_UPDATED, DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])]) + async_add_entities([SpeedtestSensor(hass.data[DOMAIN])]) # pylint: disable-next=hass-invalid-inheritance # needs fixing class SpeedtestSensor(RestoreEntity, SensorEntity): - """Implementation of a FAst.com sensor.""" + """Implementation of a Fast.com sensor.""" _attr_name = "Fast.com Download" _attr_device_class = SensorDeviceClass.DATA_RATE diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index 705eada9387..d647250b423 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -1,4 +1,14 @@ { + "config": { + "step": { + "user": { + "description": "Do you want to start the setup? The initial setup will take about 30-40 seconds." + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, "services": { "speedtest": { "name": "Speed test", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9c77bd753f8..d5a5176a974 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -141,6 +141,7 @@ FLOWS = { "evil_genius_labs", "ezviz", "faa_delays", + "fastdotcom", "fibaro", "filesize", "fireservicerota", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bdc12cceb8e..ec35b83b630 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1656,7 +1656,7 @@ "fastdotcom": { "name": "Fast.com", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "feedreader": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ed5bdefd4a..5f41b0056ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -634,6 +634,9 @@ eufylife-ble-client==0.1.8 # homeassistant.components.faa_delays faadelays==2023.9.1 +# homeassistant.components.fastdotcom +fastdotcom==0.0.3 + # homeassistant.components.feedreader feedparser==6.0.10 diff --git a/tests/components/fastdotcom/__init__.py b/tests/components/fastdotcom/__init__.py new file mode 100644 index 00000000000..4c2ca6301af --- /dev/null +++ b/tests/components/fastdotcom/__init__.py @@ -0,0 +1 @@ +"""Fast.com integration tests.""" diff --git a/tests/components/fastdotcom/test_config_flow.py b/tests/components/fastdotcom/test_config_flow.py new file mode 100644 index 00000000000..4314a7688d8 --- /dev/null +++ b/tests/components/fastdotcom/test_config_flow.py @@ -0,0 +1,74 @@ +"""Test for the Fast.com config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.fastdotcom.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_form(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.fastdotcom.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Fast.com" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +async def test_single_instance_allowed( + hass: HomeAssistant, + source: str, +) -> None: + """Test we abort if already setup.""" + mock_config_entry = MockConfigEntry(domain=DOMAIN) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test import flow.""" + with patch( + "homeassistant.components.fastdotcom.__init__.SpeedtestData", + return_value={"download": "50"}, + ), patch("homeassistant.components.fastdotcom.sensor.SpeedtestSensor"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Fast.com" + assert result["data"] == {} + assert result["options"] == {} From 9ce161c09d38564a6977f04509982572dff07d88 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Nov 2023 12:21:02 +0100 Subject: [PATCH 633/982] Update vehicle to 2.2.1 (#104299) --- homeassistant/components/rdw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index e63478976e3..f44dc7e0f12 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==2.2.0"] + "requirements": ["vehicle==2.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 171b5cca6af..294cb686879 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2678,7 +2678,7 @@ vacuum-map-parser-roborock==0.1.1 vallox-websocket-api==4.0.2 # homeassistant.components.rdw -vehicle==2.2.0 +vehicle==2.2.1 # homeassistant.components.velbus velbus-aio==2023.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f41b0056ef..d5c973fffea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1994,7 +1994,7 @@ vacuum-map-parser-roborock==0.1.1 vallox-websocket-api==4.0.2 # homeassistant.components.rdw -vehicle==2.2.0 +vehicle==2.2.1 # homeassistant.components.velbus velbus-aio==2023.11.0 From 2d38a42feac38c09ca4f5f161e15bcda41dba632 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 21 Nov 2023 12:21:27 +0100 Subject: [PATCH 634/982] Clean stt and tts codeowners (#104307) --- CODEOWNERS | 8 ++++---- homeassistant/components/stt/manifest.json | 2 +- homeassistant/components/tts/manifest.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 72c58942abc..358f2725144 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1234,8 +1234,8 @@ build.json @home-assistant/supervisor /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter /tests/components/stream/ @hunterjm @uvjustin @allenporter -/homeassistant/components/stt/ @home-assistant/core @pvizeli -/tests/components/stt/ @home-assistant/core @pvizeli +/homeassistant/components/stt/ @home-assistant/core +/tests/components/stt/ @home-assistant/core /homeassistant/components/subaru/ @G-Two /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii @@ -1342,8 +1342,8 @@ build.json @home-assistant/supervisor /tests/components/transmission/ @engrbm87 @JPHutchins /homeassistant/components/trend/ @jpbede /tests/components/trend/ @jpbede -/homeassistant/components/tts/ @home-assistant/core @pvizeli -/tests/components/tts/ @home-assistant/core @pvizeli +/homeassistant/components/tts/ @home-assistant/core +/tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck /tests/components/tuya/ @Tuya @zlinoliver @frenck /homeassistant/components/twentemilieu/ @frenck diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json index 53bb7fa1937..265c3363e2b 100644 --- a/homeassistant/components/stt/manifest.json +++ b/homeassistant/components/stt/manifest.json @@ -1,7 +1,7 @@ { "domain": "stt", "name": "Speech-to-text (STT)", - "codeowners": ["@home-assistant/core", "@pvizeli"], + "codeowners": ["@home-assistant/core"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stt", "integration_type": "entity", diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 338a8c35003..f379dc01dee 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -2,7 +2,7 @@ "domain": "tts", "name": "Text-to-speech (TTS)", "after_dependencies": ["media_player"], - "codeowners": ["@home-assistant/core", "@pvizeli"], + "codeowners": ["@home-assistant/core"], "dependencies": ["http", "ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/tts", "integration_type": "entity", From 2dd0a74b38ecb454762850b1e0c4aba9da2d0986 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 21 Nov 2023 12:45:02 +0100 Subject: [PATCH 635/982] Reolink add animal detection (#104216) --- .../components/reolink/binary_sensor.py | 11 +++++- homeassistant/components/reolink/number.py | 38 ++++++++++++++++++- homeassistant/components/reolink/strings.json | 15 ++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index bbf72056c9b..5fe14c6223c 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -71,7 +71,16 @@ BINARY_SENSORS = ( icon="mdi:dog-side", icon_off="mdi:dog-side-off", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), - supported=lambda api, ch: api.ai_supported(ch, PET_DETECTION_TYPE), + supported=lambda api, ch: api.ai_supported(ch, PET_DETECTION_TYPE) + and not api.supported(ch, "ai_animal"), + ), + ReolinkBinarySensorEntityDescription( + key=PET_DETECTION_TYPE, + translation_key="animal", + icon="mdi:paw", + icon_off="mdi:paw-off", + value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), + supported=lambda api, ch: api.supported(ch, "ai_animal"), ), ReolinkBinarySensorEntityDescription( key="visitor", diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index ef9b01a7a52..f031c385c05 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -162,7 +162,23 @@ NUMBER_ENTITIES = ( native_min_value=0, native_max_value=100, supported=lambda api, ch: ( - api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "dog_cat") + api.supported(ch, "ai_sensitivity") + and api.ai_supported(ch, "dog_cat") + and not api.supported(ch, "ai_animal") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_sensititvity", + translation_key="ai_animal_sensititvity", + icon="mdi:paw", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.supported(ch, "ai_animal") ), value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), @@ -226,7 +242,25 @@ NUMBER_ENTITIES = ( native_min_value=0, native_max_value=8, supported=lambda api, ch: ( - api.supported(ch, "ai_delay") and api.ai_supported(ch, "dog_cat") + api.supported(ch, "ai_delay") + and api.ai_supported(ch, "dog_cat") + and not api.supported(ch, "ai_animal") + ), + value=lambda api, ch: api.ai_delay(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "dog_cat"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_delay", + translation_key="ai_animal_delay", + icon="mdi:paw", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.supported(ch, "ai_animal") ), value=lambda api, ch: api.ai_delay(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "dog_cat"), diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index bab2802720d..d81e25e9887 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -75,6 +75,9 @@ "pet": { "name": "Pet" }, + "animal": { + "name": "Animal" + }, "visitor": { "name": "Visitor" }, @@ -93,6 +96,9 @@ "pet_lens_0": { "name": "Pet lens 0" }, + "animal_lens_0": { + "name": "Animal lens 0" + }, "visitor_lens_0": { "name": "Visitor lens 0" }, @@ -111,6 +117,9 @@ "pet_lens_1": { "name": "Pet lens 1" }, + "animal_lens_1": { + "name": "Animal lens 1" + }, "visitor_lens_1": { "name": "Visitor lens 1" } @@ -245,6 +254,9 @@ "ai_pet_sensititvity": { "name": "AI pet sensitivity" }, + "ai_animal_sensititvity": { + "name": "AI animal sensitivity" + }, "ai_face_delay": { "name": "AI face delay" }, @@ -257,6 +269,9 @@ "ai_pet_delay": { "name": "AI pet delay" }, + "ai_animal_delay": { + "name": "AI animal delay" + }, "auto_quick_reply_time": { "name": "Auto quick reply time" }, From dece6c80426488e831d34941b3b43e0a9de08ca0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 21 Nov 2023 14:49:10 +0100 Subject: [PATCH 636/982] Bump aiowaqi to 3.0.1 (#104314) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index f5731da2a7e..d742fd72858 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==3.0.0"] + "requirements": ["aiowaqi==3.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 294cb686879..a5ca89d25cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -383,7 +383,7 @@ aiovlc==0.1.0 aiovodafone==0.4.2 # homeassistant.components.waqi -aiowaqi==3.0.0 +aiowaqi==3.0.1 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5c973fffea..f0f8a56a2d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -356,7 +356,7 @@ aiovlc==0.1.0 aiovodafone==0.4.2 # homeassistant.components.waqi -aiowaqi==3.0.0 +aiowaqi==3.0.1 # homeassistant.components.watttime aiowatttime==0.1.1 From 6d529a82d79cca163f13f987c1c1a534b261cd22 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 21 Nov 2023 17:21:48 +0100 Subject: [PATCH 637/982] Reolink improve error handeling (#104301) * Raise proper HomeAssistantError * fix styling * Use ServiceValidationError --- homeassistant/components/reolink/button.py | 12 +++++++-- homeassistant/components/reolink/camera.py | 11 +++++--- homeassistant/components/reolink/light.py | 31 +++++++++++++++------- homeassistant/components/reolink/number.py | 9 ++++++- homeassistant/components/reolink/select.py | 9 ++++++- homeassistant/components/reolink/siren.py | 21 ++++++++++++--- homeassistant/components/reolink/switch.py | 22 ++++++++++++--- 7 files changed, 92 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index e0e067bd5f8..c98d518be03 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import GuardEnum, Host, PtzEnum +from reolink_aio.exceptions import ReolinkError from homeassistant.components.button import ( ButtonDeviceClass, @@ -15,6 +16,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -181,7 +183,10 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): async def async_press(self) -> None: """Execute the button action.""" - await self.entity_description.method(self._host.api, self._channel) + try: + await self.entity_description.method(self._host.api, self._channel) + except ReolinkError as err: + raise HomeAssistantError(err) from err class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): @@ -202,4 +207,7 @@ class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): async def async_press(self) -> None: """Execute the button action.""" - await self.entity_description.method(self._host.api) + try: + await self.entity_description.method(self._host.api) + except ReolinkError as err: + raise HomeAssistantError(err) from err diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 1bb1c14374c..ea9b84cd53f 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import logging from reolink_aio.api import DUAL_LENS_MODELS, Host +from reolink_aio.exceptions import ReolinkError from homeassistant.components.camera import ( Camera, @@ -14,6 +15,7 @@ from homeassistant.components.camera import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -147,6 +149,9 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - return await self._host.api.get_snapshot( - self._channel, self.entity_description.stream - ) + try: + return await self._host.api.get_snapshot( + self._channel, self.entity_description.stream + ) + except ReolinkError as err: + raise HomeAssistantError(err) from err diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 2f00245a0de..f1aa0cb9ee2 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -16,6 +17,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -129,9 +131,12 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" - await self.entity_description.turn_on_off_fn( - self._host.api, self._channel, False - ) + try: + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, False + ) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: @@ -140,11 +145,19 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): brightness := kwargs.get(ATTR_BRIGHTNESS) ) is not None and self.entity_description.set_brightness_fn is not None: brightness_pct = int(brightness / 255.0 * 100) - await self.entity_description.set_brightness_fn( - self._host.api, self._channel, brightness_pct - ) + try: + await self.entity_description.set_brightness_fn( + self._host.api, self._channel, brightness_pct + ) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err - await self.entity_description.turn_on_off_fn( - self._host.api, self._channel, True - ) + try: + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, True + ) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index f031c385c05..1780465850a 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.number import ( NumberEntity, @@ -15,6 +16,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -399,5 +401,10 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self.entity_description.method(self._host.api, self._channel, value) + try: + await self.entity_description.method(self._host.api, self._channel, value) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 6cf2bf9f332..566dbc92fbe 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -13,11 +13,13 @@ from reolink_aio.api import ( StatusLedEnum, TrackMethodEnum, ) +from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -161,5 +163,10 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self.entity_description.method(self._host.api, self._channel, option) + try: + await self.entity_description.method(self._host.api, self._channel, option) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index c91f633ecab..f063b65e2b4 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.siren import ( ATTR_DURATION, @@ -16,6 +17,7 @@ from homeassistant.components.siren import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -84,10 +86,23 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the siren.""" if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: - await self._host.api.set_volume(self._channel, int(volume * 100)) + try: + await self._host.api.set_volume(self._channel, int(volume * 100)) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err duration = kwargs.get(ATTR_DURATION) - await self._host.api.set_siren(self._channel, True, duration) + try: + await self._host.api.set_siren(self._channel, True, duration) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the siren.""" - await self._host.api.set_siren(self._channel, False, None) + try: + await self._host.api.set_siren(self._channel, False, None) + except ReolinkError as err: + raise HomeAssistantError(err) from err diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 0dc46d22330..eb77b16478f 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -6,11 +6,13 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import ReolinkError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -247,12 +249,18 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.method(self._host.api, self._channel, True) + try: + await self.entity_description.method(self._host.api, self._channel, True) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.entity_description.method(self._host.api, self._channel, False) + try: + await self.entity_description.method(self._host.api, self._channel, False) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() @@ -279,10 +287,16 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.method(self._host.api, True) + try: + await self.entity_description.method(self._host.api, True) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.entity_description.method(self._host.api, False) + try: + await self.entity_description.method(self._host.api, False) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() From c929b70fba18d0dfb798c879b85eb1152e0fd4b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Nov 2023 17:41:04 +0100 Subject: [PATCH 638/982] Bump pychromecast to 13.0.8 (#104320) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 7cf318f12a6..5035b3c6620 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==13.0.7"], + "requirements": ["PyChromecast==13.0.8"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index a5ca89d25cc..ac3ee8d8b36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -54,7 +54,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==13.0.7 +PyChromecast==13.0.8 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0f8a56a2d4..73610af37f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ PlexAPI==4.15.4 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==13.0.7 +PyChromecast==13.0.8 # homeassistant.components.flick_electric PyFlick==0.0.2 From 4c7da97eca141dea809890f9a39ed661a6e540fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Nov 2023 18:36:50 +0100 Subject: [PATCH 639/982] Bump python-matter-server to 4.0.2 (#103760) Co-authored-by: Marcel van der Veldt --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../matter/fixtures/config_entry_diagnostics_redacted.json | 1 + tests/components/matter/fixtures/nodes/device_diagnostics.json | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 6f494153a97..174ebb1cab9 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==4.0.0"] + "requirements": ["python-matter-server==4.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac3ee8d8b36..f61995095d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2164,7 +2164,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.0 +python-matter-server==4.0.2 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73610af37f3..3cc46149751 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1615,7 +1615,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.0 +python-matter-server==4.0.2 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 3c5b82ad5b8..8a67ef0fb63 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -14,6 +14,7 @@ "node_id": 5, "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", + "last_subscription_attempt": 0, "interview_version": 2, "attributes": { "0/4/0": 128, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 4b834cd9090..3abecbdf66f 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -3,6 +3,7 @@ "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", "interview_version": 2, + "last_subscription_attempt": 0, "attributes": { "0/4/0": 128, "0/4/65532": 1, From 95e322c52e805bc8d3f0e3072571e419602185ba Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Nov 2023 10:47:06 -0800 Subject: [PATCH 640/982] Set Motion Blinds battery sensor as a diagnostic (#104329) --- homeassistant/components/motion_blinds/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index d8dc25e0006..e71abe09069 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -48,6 +48,7 @@ class MotionBatterySensor(MotionCoordinatorEntity, SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" From b604c1c22251873e0dac629a08d859ec1818856e Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 21 Nov 2023 20:25:07 +0100 Subject: [PATCH 641/982] Fix discovery schema for Matter switches (#103762) * Fix discovery schema for matter switches * fix typo in function that generates device name * add test for switchunit --- homeassistant/components/matter/adapter.py | 4 +- homeassistant/components/matter/switch.py | 11 +- .../matter/fixtures/nodes/switch-unit.json | 119 ++++++++++++++++++ tests/components/matter/test_switch.py | 41 ++++-- 4 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/switch-unit.json diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 52b8e905b4b..2831ebe9a38 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -145,9 +145,7 @@ class MatterAdapter: get_clean_name(basic_info.nodeLabel) or get_clean_name(basic_info.productLabel) or get_clean_name(basic_info.productName) - or device_type.__name__ - if device_type - else None + or (device_type.__name__ if device_type else None) ) # handle bridged devices diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index e1fb4464b83..61922e8e8c9 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -67,7 +67,15 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), - # restrict device type to prevent discovery by the wrong platform + device_type=(device_types.OnOffPlugInUnit,), + ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=SwitchEntityDescription( + key="MatterSwitch", device_class=SwitchDeviceClass.SWITCH, name=None + ), + entity_class=MatterSwitch, + required_attributes=(clusters.OnOff.Attributes.OnOff,), not_device_type=( device_types.ColorTemperatureLight, device_types.DimmableLight, @@ -76,7 +84,6 @@ DISCOVERY_SCHEMAS = [ device_types.DoorLock, device_types.ColorDimmerSwitch, device_types.DimmerSwitch, - device_types.OnOffLightSwitch, device_types.Thermostat, ), ), diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch-unit.json new file mode 100644 index 00000000000..ceed22d2524 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -0,0 +1,119 @@ +{ + "node_id": 1, + "date_commissioned": "2022-11-29T21:23:48.485051", + "last_interview": "2022-11-29T21:23:48.485057", + "interview_version": 2, + "attributes": { + "0/29/0": [ + { + "deviceType": 99999, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock SwitchUnit", + "0/40/4": 32768, + "0/40/5": "Mock SwitchUnit", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20221206", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-switch-unit", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/5/0": 0, + "1/5/1": 0, + "1/5/2": 0, + "1/5/3": false, + "1/5/4": 0, + "1/5/65532": 0, + "1/5/65533": 4, + "1/5/65528": [0, 1, 2, 3, 4, 6], + "1/5/65529": [0, 1, 2, 3, 4, 5, 6], + "1/5/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/7/0": 0, + "1/7/16": 0, + "1/7/65532": 0, + "1/7/65533": 1, + "1/7/65528": [], + "1/7/65529": [], + "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "deviceType": 9999999, + "revision": 1 + } + ], + "1/29/1": [ + 3, 4, 5, 6, 7, 8, 15, 29, 30, 37, 47, 59, 64, 65, 69, 80, 257, 258, 259, + 512, 513, 514, 516, 768, 1024, 1026, 1027, 1028, 1029, 1030, 1283, 1284, + 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 2820, + 4294048773 + ], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, + "available": true, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 6fbe5d58f28..ac03d731ee1 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -14,22 +14,30 @@ from .common import ( ) -@pytest.fixture(name="switch_node") -async def switch_node_fixture( +@pytest.fixture(name="powerplug_node") +async def powerplug_node_fixture( hass: HomeAssistant, matter_client: MagicMock ) -> MatterNode: - """Fixture for a switch node.""" + """Fixture for a Powerplug node.""" return await setup_integration_with_node_fixture( hass, "on-off-plugin-unit", matter_client ) +@pytest.fixture(name="switch_unit") +async def switch_unit_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Switch Unit node.""" + return await setup_integration_with_node_fixture(hass, "switch-unit", matter_client) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_turn_on( hass: HomeAssistant, matter_client: MagicMock, - switch_node: MatterNode, + powerplug_node: MatterNode, ) -> None: """Test turning on a switch.""" state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -47,12 +55,12 @@ async def test_turn_on( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( - node_id=switch_node.node_id, + node_id=powerplug_node.node_id, endpoint_id=1, command=clusters.OnOff.Commands.On(), ) - set_node_attribute(switch_node, 1, 6, 0, True) + set_node_attribute(powerplug_node, 1, 6, 0, True) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -65,7 +73,7 @@ async def test_turn_on( async def test_turn_off( hass: HomeAssistant, matter_client: MagicMock, - switch_node: MatterNode, + powerplug_node: MatterNode, ) -> None: """Test turning off a switch.""" state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -83,7 +91,24 @@ async def test_turn_off( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( - node_id=switch_node.node_id, + node_id=powerplug_node.node_id, endpoint_id=1, command=clusters.OnOff.Commands.Off(), ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_switch_unit( + hass: HomeAssistant, + matter_client: MagicMock, + switch_unit: MatterNode, +) -> None: + """Test if a switch entity is discovered from any (non-light) OnOf cluster device.""" + # A switch entity should be discovered as fallback for ANY Matter device (endpoint) + # that has the OnOff cluster and does not fall into an explicit discovery schema + # by another platform (e.g. light, lock etc.). + state = hass.states.get("switch.mock_switchunit") + assert state + assert state.state == "off" + assert state.attributes["friendly_name"] == "Mock SwitchUnit" From f45d373e1727ab01dc6106687a0279c641d37239 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 21 Nov 2023 14:45:53 -0500 Subject: [PATCH 642/982] Make non-selected Roborock images diagnostic (#104233) * Make images diagnostic * Add return type --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/roborock/image.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 6c4c7553c14..5e61bb1d408 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -11,6 +11,7 @@ from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -66,10 +67,22 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): self.map_flag = map_flag self.cached_map = self._create_image(starting_map) + @property + def entity_category(self) -> EntityCategory | None: + """Return diagnostic entity category for any non-selected maps.""" + if not self.is_selected: + return EntityCategory.DIAGNOSTIC + return None + + @property + def is_selected(self) -> bool: + """Return if this map is the currently selected map.""" + return self.map_flag == self.coordinator.current_map + def is_map_valid(self) -> bool: """Update this map if it is the current active map, and the vacuum is cleaning.""" return ( - self.map_flag == self.coordinator.current_map + self.is_selected and self.image_last_updated is not None and self.coordinator.roborock_device_info.props.status is not None and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) From 33c5d1855d2d33bfd87a1e458c48cd315f81c0b6 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Tue, 21 Nov 2023 16:40:05 -0500 Subject: [PATCH 643/982] Rewrite APCUPSD sensors using DataUpdateCoordinator (#88467) * Add test sensor. * Fix sensor test file name. * Add binary sensor test. * Fix comments and styling. * Remove apcupsd from omissions in coveragerc. * Revert "Remove apcupsd from omissions in coveragerc." This reverts commit 66b05fcb8829619a771a650a3d70174089e15d91. * Implement the data coordinator for apcupsd. * Add tests for sensor updates and throttles. * Reorder the statement for better code clarity. * Update docstring. * Add more tests for checking if the coordinator works ok. * Implement a custom debouncer with 5 second cooldown for the coordinator. * Add more tests for checking if our integration is able to properly mark entity's availability. * Make apcupsd a silver integration. * Try to fix non-deterministic test behaviors * Fix JSON format * Use new `with` format in python 3.10 for better readability * Update tests. * Rebase and simplify code. * Add an ups prefix to the property methods of the coordinator * Replace init_integration with async_init_integration * Lint fixes * Fix imports * Update BinarySensor implementation to add initial update of attributes * Fix test failures due to rebases * Reorder the statements for better code clarity * Fix incorrect references to the ups_name property * Simplify BinarySensor value getter code * No need to update when adding coordinator-controlled sensors --- .coveragerc | 3 - homeassistant/components/apcupsd/__init__.py | 96 +++++++++------- .../components/apcupsd/binary_sensor.py | 48 ++++---- .../components/apcupsd/config_flow.py | 35 +++--- .../components/apcupsd/manifest.json | 1 + homeassistant/components/apcupsd/sensor.py | 56 +++++----- tests/components/apcupsd/__init__.py | 5 +- tests/components/apcupsd/test_config_flow.py | 32 +++--- tests/components/apcupsd/test_init.py | 58 ++++++++-- tests/components/apcupsd/test_sensor.py | 104 ++++++++++++++++++ 10 files changed, 296 insertions(+), 142 deletions(-) diff --git a/.coveragerc b/.coveragerc index cdb5bdd07e4..8ce4645bf0c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -67,9 +67,6 @@ omit = homeassistant/components/android_ip_webcam/switch.py homeassistant/components/anel_pwrctrl/switch.py homeassistant/components/anthemav/media_player.py - homeassistant/components/apcupsd/__init__.py - homeassistant/components/apcupsd/binary_sensor.py - homeassistant/components/apcupsd/sensor.py homeassistant/components/apple_tv/__init__.py homeassistant/components/apple_tv/browse_media.py homeassistant/components/apple_tv/media_player.py diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 8d7c6b2f46d..7a99cafb405 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -1,44 +1,46 @@ """Support for APCUPSd via its Network Information Server (NIS).""" from __future__ import annotations +from collections import OrderedDict from datetime import timedelta import logging -from typing import Any, Final +from typing import Final from apcaccess import status +import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import ( + REQUEST_REFRESH_DEFAULT_IMMEDIATE, + DataUpdateCoordinator, + UpdateFailed, +) _LOGGER = logging.getLogger(__name__) DOMAIN: Final = "apcupsd" -VALUE_ONLINE: Final = 8 PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR) -MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=60) +UPDATE_INTERVAL: Final = timedelta(seconds=60) +REQUEST_REFRESH_COOLDOWN: Final = 5 CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Use config values to set up a function enabling status retrieval.""" - data_service = APCUPSdData( - config_entry.data[CONF_HOST], config_entry.data[CONF_PORT] - ) + host, port = config_entry.data[CONF_HOST], config_entry.data[CONF_PORT] + coordinator = APCUPSdCoordinator(hass, host, port) - try: - await hass.async_add_executor_job(data_service.update) - except OSError as ex: - _LOGGER.error("Failure while testing APCUPSd status retrieval: %s", ex) - return False + await coordinator.async_config_entry_first_refresh() - # Store the data service object. + # Store the coordinator for later uses. hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = data_service + hass.data[DOMAIN][config_entry.entry_id] = coordinator # Forward the config entries to the supported platforms. await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -53,64 +55,78 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class APCUPSdData: - """Stores the data retrieved from APCUPSd. +class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): + """Store and coordinate the data retrieved from APCUPSd for all sensors. For each entity to use, acts as the single point responsible for fetching updates from the server. """ - def __init__(self, host: str, port: int) -> None: + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: """Initialize the data object.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, + _LOGGER, + cooldown=REQUEST_REFRESH_COOLDOWN, + immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, + ), + ) self._host = host self._port = port - self.status: dict[str, str] = {} @property - def name(self) -> str | None: + def ups_name(self) -> str | None: """Return the name of the UPS, if available.""" - return self.status.get("UPSNAME") + return self.data.get("UPSNAME") @property - def model(self) -> str | None: + def ups_model(self) -> str | None: """Return the model of the UPS, if available.""" # Different UPS models may report slightly different keys for model, here we # try them all. for model_key in ("APCMODEL", "MODEL"): - if model_key in self.status: - return self.status[model_key] + if model_key in self.data: + return self.data[model_key] return None @property - def serial_no(self) -> str | None: + def ups_serial_no(self) -> str | None: """Return the unique serial number of the UPS, if available.""" - return self.status.get("SERIALNO") - - @property - def statflag(self) -> str | None: - """Return the STATFLAG indicating the status of the UPS, if available.""" - return self.status.get("STATFLAG") + return self.data.get("SERIALNO") @property def device_info(self) -> DeviceInfo | None: - """Return the DeviceInfo of this APC UPS for the sensors, if serial number is available.""" - if self.serial_no is None: + """Return the DeviceInfo of this APC UPS, if serial number is available.""" + if not self.ups_serial_no: return None return DeviceInfo( - identifiers={(DOMAIN, self.serial_no)}, - model=self.model, + identifiers={(DOMAIN, self.ups_serial_no)}, + model=self.ups_model, manufacturer="APC", - name=self.name if self.name is not None else "APC UPS", - hw_version=self.status.get("FIRMWARE"), - sw_version=self.status.get("VERSION"), + name=self.ups_name if self.ups_name else "APC UPS", + hw_version=self.data.get("FIRMWARE"), + sw_version=self.data.get("VERSION"), ) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self, **kwargs: Any) -> None: + async def _async_update_data(self) -> OrderedDict[str, str]: """Fetch the latest status from APCUPSd. Note that the result dict uses upper case for each resource, where our integration uses lower cases as keys internally. """ - self.status = status.parse(status.get(host=self._host, port=self._port)) + + async with async_timeout.timeout(10): + try: + raw = await self.hass.async_add_executor_job( + status.get, self._host, self._port + ) + result: OrderedDict[str, str] = status.parse(raw) + return result + except OSError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index bac8d18d58b..76e88689ca5 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Final from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -10,8 +11,9 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, VALUE_ONLINE, APCUPSdData +from . import DOMAIN, APCUPSdCoordinator _LOGGER = logging.getLogger(__name__) _DESCRIPTION = BinarySensorEntityDescription( @@ -19,6 +21,8 @@ _DESCRIPTION = BinarySensorEntityDescription( name="UPS Online Status", icon="mdi:heart", ) +# The bit in STATFLAG that indicates the online status of the APC UPS. +_VALUE_ONLINE_MASK: Final = 0b1000 async def async_setup_entry( @@ -27,50 +31,36 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up an APCUPSd Online Status binary sensor.""" - data_service: APCUPSdData = hass.data[DOMAIN][config_entry.entry_id] + coordinator: APCUPSdCoordinator = hass.data[DOMAIN][config_entry.entry_id] # Do not create the binary sensor if APCUPSd does not provide STATFLAG field for us # to determine the online status. - if data_service.statflag is None: + if _DESCRIPTION.key.upper() not in coordinator.data: return - async_add_entities( - [OnlineStatus(data_service, _DESCRIPTION)], - update_before_add=True, - ) + async_add_entities([OnlineStatus(coordinator, _DESCRIPTION)]) -class OnlineStatus(BinarySensorEntity): +class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity): """Representation of a UPS online status.""" def __init__( self, - data_service: APCUPSdData, + coordinator: APCUPSdCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialize the APCUPSd binary device.""" + super().__init__(coordinator, context=description.key.upper()) + # Set up unique id and device info if serial number is available. - if (serial_no := data_service.serial_no) is not None: + if (serial_no := coordinator.ups_serial_no) is not None: self._attr_unique_id = f"{serial_no}_{description.key}" - self._attr_device_info = data_service.device_info - self.entity_description = description - self._data_service = data_service + self._attr_device_info = coordinator.device_info - def update(self) -> None: - """Get the status report from APCUPSd and set this entity's state.""" - try: - self._data_service.update() - except OSError as ex: - if self._attr_available: - self._attr_available = False - _LOGGER.exception("Got exception while fetching state: %s", ex) - return - - self._attr_available = True + @property + def is_on(self) -> bool | None: + """Returns true if the UPS is online.""" + # Check if ONLINE bit is set in STATFLAG. key = self.entity_description.key.upper() - if key not in self._data_service.status: - self._attr_is_on = None - return - - self._attr_is_on = int(self._data_service.status[key], 16) & VALUE_ONLINE > 0 + return int(self.coordinator.data[key], 16) & _VALUE_ONLINE_MASK != 0 diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index f1ce20694c7..57002d7a2b2 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -1,6 +1,7 @@ """Config flow for APCUPSd integration.""" from __future__ import annotations +import asyncio from typing import Any import voluptuous as vol @@ -10,8 +11,9 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import UpdateFailed -from . import DOMAIN, APCUPSdData +from . import DOMAIN, APCUPSdCoordinator _PORT_SELECTOR = vol.All( selector.NumberSelector( @@ -43,36 +45,37 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="user", data_schema=_SCHEMA) + host, port = user_input[CONF_HOST], user_input[CONF_PORT] + # Abort if an entry with same host and port is present. - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) + self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) # Test the connection to the host and get the current status for serial number. - data_service = APCUPSdData(user_input[CONF_HOST], user_input[CONF_PORT]) - try: - await self.hass.async_add_executor_job(data_service.update) - except OSError: + coordinator = APCUPSdCoordinator(self.hass, host, port) + + await coordinator.async_request_refresh() + await self.hass.async_block_till_done() + if isinstance(coordinator.last_exception, (UpdateFailed, asyncio.TimeoutError)): errors = {"base": "cannot_connect"} return self.async_show_form( step_id="user", data_schema=_SCHEMA, errors=errors ) - if not data_service.status: + if not coordinator.data: return self.async_abort(reason="no_status") # We _try_ to use the serial number of the UPS as the unique id since this field # is not guaranteed to exist on all APC UPS models. - await self.async_set_unique_id(data_service.serial_no) + await self.async_set_unique_id(coordinator.ups_serial_no) self._abort_if_unique_id_configured() title = "APC UPS" - if data_service.name is not None: - title = data_service.name - elif data_service.model is not None: - title = data_service.model - elif data_service.serial_no is not None: - title = data_service.serial_no + if coordinator.ups_name is not None: + title = coordinator.ups_name + elif coordinator.ups_model is not None: + title = coordinator.ups_model + elif coordinator.ups_serial_no is not None: + title = coordinator.ups_serial_no return self.async_create_entry( title=title, diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index cd7e2a116b3..55b66f0c0a0 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/apcupsd", "iot_class": "local_polling", "loggers": ["apcaccess"], + "quality_scale": "silver", "requirements": ["apcaccess==0.0.13"] } diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 745be7e2d63..71dc9940b72 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -20,10 +20,11 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, APCUPSdData +from . import DOMAIN, APCUPSdCoordinator _LOGGER = logging.getLogger(__name__) @@ -452,11 +453,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the APCUPSd sensors from config entries.""" - data_service: APCUPSdData = hass.data[DOMAIN][config_entry.entry_id] + coordinator: APCUPSdCoordinator = hass.data[DOMAIN][config_entry.entry_id] - # The resources from data service are in upper-case by default, but we use - # lower cases throughout this integration. - available_resources: set[str] = {k.lower() for k, _ in data_service.status.items()} + # The resource keys in the data dict collected in the coordinator is in upper-case + # by default, but we use lower cases throughout this integration. + available_resources: set[str] = {k.lower() for k, _ in coordinator.data.items()} entities = [] for resource in available_resources: @@ -464,9 +465,9 @@ async def async_setup_entry( _LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper()) continue - entities.append(APCUPSdSensor(data_service, SENSORS[resource])) + entities.append(APCUPSdSensor(coordinator, SENSORS[resource])) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) def infer_unit(value: str) -> tuple[str, str | None]: @@ -483,41 +484,36 @@ def infer_unit(value: str) -> tuple[str, str | None]: return value, None -class APCUPSdSensor(SensorEntity): +class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity): """Representation of a sensor entity for APCUPSd status values.""" def __init__( self, - data_service: APCUPSdData, + coordinator: APCUPSdCoordinator, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator=coordinator, context=description.key.upper()) + # Set up unique id and device info if serial number is available. - if (serial_no := data_service.serial_no) is not None: + if (serial_no := coordinator.ups_serial_no) is not None: self._attr_unique_id = f"{serial_no}_{description.key}" - self._attr_device_info = data_service.device_info self.entity_description = description - self._data_service = data_service + self._attr_device_info = coordinator.device_info - def update(self) -> None: - """Get the latest status and use it to update our sensor state.""" - try: - self._data_service.update() - except OSError as ex: - if self._attr_available: - self._attr_available = False - _LOGGER.exception("Got exception while fetching state: %s", ex) - return + # Initial update of attributes. + self._update_attrs() - self._attr_available = True + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + self.async_write_ha_state() + + def _update_attrs(self) -> None: + """Update sensor attributes based on coordinator data.""" key = self.entity_description.key.upper() - if key not in self._data_service.status: - self._attr_native_value = None - return - - self._attr_native_value, inferred_unit = infer_unit( - self._data_service.status[key] - ) + self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key]) if not self.native_unit_of_measurement: self._attr_native_unit_of_measurement = inferred_unit diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index b8a83f950d0..b0eee051331 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -95,8 +95,9 @@ async def async_init_integration( entry.add_to_hass(hass) - with patch("apcaccess.status.parse", return_value=status), patch( - "apcaccess.status.get", return_value=b"" + with ( + patch("apcaccess.status.parse", return_value=status), + patch("apcaccess.status.get", return_value=b""), ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 6ac7992f404..48d57890320 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -38,10 +38,10 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: async def test_config_flow_no_status(hass: HomeAssistant) -> None: """Test config flow setup with successful connection but no status is reported.""" - with patch( - "apcaccess.status.parse", - return_value={}, # Returns no status. - ), patch("apcaccess.status.get", return_value=b""): + with ( + patch("apcaccess.status.parse", return_value={}), # Returns no status. + patch("apcaccess.status.get", return_value=b""), + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -63,9 +63,11 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: ) mock_entry.add_to_hass(hass) - with patch("apcaccess.status.parse") as mock_parse, patch( - "apcaccess.status.get", return_value=b"" - ), _patch_setup(): + with ( + patch("apcaccess.status.parse") as mock_parse, + patch("apcaccess.status.get", return_value=b""), + _patch_setup(), + ): mock_parse.return_value = MOCK_STATUS # Now, create the integration again using the same config data, we should reject @@ -109,9 +111,11 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: async def test_flow_works(hass: HomeAssistant) -> None: """Test successful creation of config entries via user configuration.""" - with patch("apcaccess.status.parse", return_value=MOCK_STATUS), patch( - "apcaccess.status.get", return_value=b"" - ), _patch_setup() as mock_setup: + with ( + patch("apcaccess.status.parse", return_value=MOCK_STATUS), + patch("apcaccess.status.get", return_value=b""), + _patch_setup() as mock_setup, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -147,9 +151,11 @@ async def test_flow_minimal_status( We test different combinations of minimal statuses, where the title of the integration will vary. """ - with patch("apcaccess.status.parse") as mock_parse, patch( - "apcaccess.status.get", return_value=b"" - ), _patch_setup() as mock_setup: + with ( + patch("apcaccess.status.parse") as mock_parse, + patch("apcaccess.status.get", return_value=b""), + _patch_setup() as mock_setup, + ): status = MOCK_MINIMAL_STATUS | extra_status mock_parse.return_value = status diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 9bdcc89a9a3..43eab28eb46 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -4,15 +4,16 @@ from unittest.mock import patch import pytest -from homeassistant.components.apcupsd import DOMAIN +from homeassistant.components.apcupsd import DOMAIN, UPDATE_INTERVAL from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.util import utcnow from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize("status", (MOCK_STATUS, MOCK_MINIMAL_STATUS)) @@ -67,11 +68,11 @@ async def test_device_entry( for field, entry_value in fields.items(): if field in status: assert entry_value == status[field] + # Even if UPSNAME is not available, we must fall back to default "APC UPS". elif field == "UPSNAME": - # Even if UPSNAME is not available, we must fall back to default "APC UPS". assert entry_value == "APC UPS" else: - assert entry_value is None + assert not entry_value assert entry.manufacturer == "APC" @@ -107,15 +108,16 @@ async def test_connection_error(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - with patch("apcaccess.status.parse", side_effect=OSError()), patch( - "apcaccess.status.get" + with ( + patch("apcaccess.status.parse", side_effect=OSError()), + patch("apcaccess.status.get"), ): await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_remove(hass: HomeAssistant) -> None: - """Test successful unload of entry.""" +async def test_unload_remove_entry(hass: HomeAssistant) -> None: + """Test successful unload and removal of an entry.""" # Load two integrations from two mock hosts. entries = ( await async_init_integration(hass, host="test1", status=MOCK_STATUS), @@ -142,3 +144,41 @@ async def test_unload_remove(hass: HomeAssistant) -> None: await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + +async def test_availability(hass: HomeAssistant) -> None: + """Ensure that we mark the entity's availability properly when network is down / back up.""" + await async_init_integration(hass) + + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert pytest.approx(float(state.state)) == 14.0 + + with ( + patch("apcaccess.status.parse") as mock_parse, + patch("apcaccess.status.get", return_value=b""), + ): + # Mock a network error and then trigger an auto-polling event. + mock_parse.side_effect = OSError() + future = utcnow() + UPDATE_INTERVAL + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # Sensors should be marked as unavailable. + state = hass.states.get("sensor.ups_load") + assert state + assert state.state == STATE_UNAVAILABLE + + # Reset the API to return a new status and update. + mock_parse.side_effect = None + mock_parse.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} + future = future + UPDATE_INTERVAL + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # Sensors should be online now with the new value. + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert pytest.approx(float(state.state)) == 15.0 diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 743b1f87847..73546613002 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -1,5 +1,9 @@ """Test sensors of APCUPSd integration.""" +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.components.apcupsd import REQUEST_REFRESH_COOLDOWN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -7,17 +11,23 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, + STATE_UNAVAILABLE, UnitOfElectricPotential, UnitOfPower, UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from . import MOCK_STATUS, async_init_integration +from tests.common import async_fire_time_changed + async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of sensor.""" @@ -105,3 +115,97 @@ async def test_sensor_disabled( assert updated_entry != entry assert updated_entry.disabled is False + + +async def test_state_update(hass: HomeAssistant) -> None: + """Ensure the sensor state changes after updating the data.""" + await async_init_integration(hass) + + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "14.0" + + new_status = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} + with ( + patch("apcaccess.status.parse", return_value=new_status), + patch("apcaccess.status.get", return_value=b""), + ): + future = utcnow() + timedelta(minutes=2) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "15.0" + + +async def test_manual_update_entity(hass: HomeAssistant) -> None: + """Test manual update entity via service homeassistant/update_entity.""" + await async_init_integration(hass) + + # Assert the initial state of sensor.ups_load. + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "14.0" + + # Setup HASS for calling the update_entity service. + await async_setup_component(hass, "homeassistant", {}) + + with ( + patch("apcaccess.status.parse") as mock_parse, + patch("apcaccess.status.get", return_value=b"") as mock_get, + ): + mock_parse.return_value = MOCK_STATUS | { + "LOADPCT": "15.0 Percent", + "BCHARGE": "99.0 Percent", + } + # Now, we fast-forward the time to pass the debouncer cooldown, but put it + # before the normal update interval to see if the manual update works. + future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) + async_fire_time_changed(hass, future) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.ups_load", "sensor.ups_battery"]}, + blocking=True, + ) + # Even if we requested updates for two entities, our integration should smartly + # group the API calls to just one. + assert mock_parse.call_count == 1 + assert mock_get.call_count == 1 + + # The new state should be effective. + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "15.0" + + +async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: + """Test multiple simultaneous manual update entity via service homeassistant/update_entity. + + We should only do network call once for the multiple simultaneous update entity services. + """ + await async_init_integration(hass) + + # Setup HASS for calling the update_entity service. + await async_setup_component(hass, "homeassistant", {}) + + with ( + patch("apcaccess.status.parse", return_value=MOCK_STATUS) as mock_parse, + patch("apcaccess.status.get", return_value=b"") as mock_get, + ): + # Fast-forward time to just pass the initial debouncer cooldown. + future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) + async_fire_time_changed(hass, future) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.ups_load", "sensor.ups_input_voltage"]}, + blocking=True, + ) + assert mock_parse.call_count == 1 + assert mock_get.call_count == 1 From 91e0a53cb2fd0fa62f11bff5a7ae78f90ec95a24 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 21 Nov 2023 23:29:46 +0100 Subject: [PATCH 644/982] Move to asyncio.timeout for APC integration (#104340) Move to asyncio.timeout for apcupsd --- homeassistant/components/apcupsd/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 7a99cafb405..8431d282e7d 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -1,13 +1,13 @@ """Support for APCUPSd via its Network Information Server (NIS).""" from __future__ import annotations +import asyncio from collections import OrderedDict from datetime import timedelta import logging from typing import Final from apcaccess import status -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform @@ -121,7 +121,7 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): integration uses lower cases as keys internally. """ - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: raw = await self.hass.async_add_executor_job( status.get, self._host, self._port From aea15ee20c05529e6346e32d9b463205bf552b0f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 21 Nov 2023 23:43:56 +0100 Subject: [PATCH 645/982] Reolink add media browser for playback of recordings (#103407) --- .../components/reolink/media_source.py | 330 ++++++++++++++++++ tests/components/reolink/conftest.py | 6 + tests/components/reolink/test_media_source.py | 288 +++++++++++++++ 3 files changed, 624 insertions(+) create mode 100644 homeassistant/components/reolink/media_source.py create mode 100644 tests/components/reolink/test_media_source.py diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py new file mode 100644 index 00000000000..6a350e13836 --- /dev/null +++ b/homeassistant/components/reolink/media_source.py @@ -0,0 +1,330 @@ +"""Expose Reolink IP camera VODs as media sources.""" + +from __future__ import annotations + +import datetime as dt +import logging + +from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings +from homeassistant.components.media_player import MediaClass, MediaType +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.components.stream import create_stream +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ReolinkData +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_media_source(hass: HomeAssistant) -> ReolinkVODMediaSource: + """Set up camera media source.""" + return ReolinkVODMediaSource(hass) + + +def res_name(stream: str) -> str: + """Return the user friendly name for a stream.""" + return "High res." if stream == "main" else "Low res." + + +class ReolinkVODMediaSource(MediaSource): + """Provide Reolink camera VODs as media sources.""" + + name: str = "Reolink" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize ReolinkVODMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + self.data: dict[str, ReolinkData] = hass.data[DOMAIN] + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + identifier = item.identifier.split("|", 5) + if identifier[0] != "FILE": + raise Unresolvable(f"Unknown media item '{item.identifier}'.") + + _, config_entry_id, channel_str, stream_res, filename = identifier + channel = int(channel_str) + + host = self.data[config_entry_id].host + mime_type, url = await host.api.get_vod_source(channel, filename, stream_res) + if _LOGGER.isEnabledFor(logging.DEBUG): + url_log = f"{url.split('&user=')[0]}&user=xxxxx&password=xxxxx" + _LOGGER.debug( + "Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log + ) + + stream = create_stream(self.hass, url, {}, DynamicStreamSettings()) + stream.add_provider("hls", timeout=3600) + stream_url: str = stream.endpoint_url("hls") + stream_url = stream_url.replace("master_", "") + return PlayMedia(stream_url, mime_type) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if item.identifier is None: + return await self._async_generate_root() + + identifier = item.identifier.split("|", 7) + item_type = identifier[0] + + if item_type == "CAM": + _, config_entry_id, channel_str = identifier + return await self._async_generate_resolution_select( + config_entry_id, int(channel_str) + ) + if item_type == "RES": + _, config_entry_id, channel_str, stream = identifier + return await self._async_generate_camera_days( + config_entry_id, int(channel_str), stream + ) + if item_type == "DAY": + ( + _, + config_entry_id, + channel_str, + stream, + year_str, + month_str, + day_str, + ) = identifier + return await self._async_generate_camera_files( + config_entry_id, + int(channel_str), + stream, + int(year_str), + int(month_str), + int(day_str), + ) + + raise Unresolvable(f"Unknown media item '{item.identifier}' during browsing.") + + async def _async_generate_root(self) -> BrowseMediaSource: + """Return all available reolink cameras as root browsing structure.""" + children: list[BrowseMediaSource] = [] + + entity_reg = er.async_get(self.hass) + device_reg = dr.async_get(self.hass) + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + if config_entry.state != ConfigEntryState.LOADED: + continue + channels: list[str] = [] + host = self.data[config_entry.entry_id].host + entities = er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + for entity in entities: + if ( + entity.disabled + or entity.device_id is None + or entity.domain != CAM_DOMAIN + ): + continue + + device = device_reg.async_get(entity.device_id) + ch = entity.unique_id.split("_")[1] + if ch in channels or device is None: + continue + channels.append(ch) + + if ( + host.api.api_version("recReplay", int(ch)) < 1 + or not host.api.hdd_info + ): + # playback stream not supported by this camera or no storage installed + continue + + device_name = device.name + if device.name_by_user is not None: + device_name = device.name_by_user + + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"CAM|{config_entry.entry_id}|{ch}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=device_name, + thumbnail=f"/api/camera_proxy/{entity.entity_id}", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.APP, + media_content_type="", + title="Reolink", + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_resolution_select( + self, config_entry_id: str, channel: int + ) -> BrowseMediaSource: + """Allow the user to select the high or low playback resolution, (low loads faster).""" + host = self.data[config_entry_id].host + + main_enc = await host.api.get_encoding(channel, "main") + if main_enc == "h265": + _LOGGER.debug( + "Reolink camera %s uses h265 encoding for main stream," + "playback only possible using sub stream", + host.api.camera_name(channel), + ) + return await self._async_generate_camera_days( + config_entry_id, channel, "sub" + ) + + children = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|sub", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="Low resolution", + can_play=False, + can_expand=True, + ), + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|main", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="High resolution", + can_play=False, + can_expand=True, + ), + ] + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"RESs|{config_entry_id}|{channel}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=host.api.camera_name(channel), + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_camera_days( + self, config_entry_id: str, channel: int, stream: str + ) -> BrowseMediaSource: + """Return all days on which recordings are available for a reolink camera.""" + host = self.data[config_entry_id].host + + # We want today of the camera, not necessarily today of the server + now = host.api.time() or await host.api.async_get_time() + start = now - dt.timedelta(days=31) + end = now + + children: list[BrowseMediaSource] = [] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requesting recording days of %s from %s to %s", + host.api.camera_name(channel), + start, + end, + ) + statuses, _ = await host.api.request_vod_files( + channel, start, end, status_only=True, stream=stream + ) + for status in statuses: + for day in status.days: + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"DAY|{config_entry_id}|{channel}|{stream}|{status.year}|{status.month}|{day}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaType.PLAYLIST, + title=f"{status.year}/{status.month}/{day}", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"DAYS|{config_entry_id}|{channel}|{stream}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=f"{host.api.camera_name(channel)} {res_name(stream)}", + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_camera_files( + self, + config_entry_id: str, + channel: int, + stream: str, + year: int, + month: int, + day: int, + ) -> BrowseMediaSource: + """Return all recording files on a specific day of a Reolink camera.""" + host = self.data[config_entry_id].host + + start = dt.datetime(year, month, day, hour=0, minute=0, second=0) + end = dt.datetime(year, month, day, hour=23, minute=59, second=59) + + children: list[BrowseMediaSource] = [] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requesting VODs of %s on %s/%s/%s", + host.api.camera_name(channel), + year, + month, + day, + ) + _, vod_files = await host.api.request_vod_files( + channel, start, end, stream=stream + ) + for file in vod_files: + file_name = f"{file.start_time.time()} {file.duration}" + if file.triggers != file.triggers.NONE: + file_name += " " + " ".join( + str(trigger.name).title() + for trigger in file.triggers + if trigger != trigger.NONE + ) + + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"FILE|{config_entry_id}|{channel}|{stream}|{file.file_name}", + media_class=MediaClass.VIDEO, + media_content_type=MediaType.VIDEO, + title=file_name, + can_play=True, + can_expand=False, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"FILES|{config_entry_id}|{channel}|{stream}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=f"{host.api.camera_name(channel)} {res_name(stream)} {year}/{month}/{day}", + can_play=False, + can_expand=True, + children=children, + ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 3efc1e481df..2a6fd0fecd3 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -19,8 +19,10 @@ TEST_USERNAME2 = "username" TEST_PASSWORD = "password" TEST_PASSWORD2 = "new_password" TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_MAC2 = "12:34:56:78:9a:bc" TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" +TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True @@ -59,11 +61,15 @@ def reolink_connect_class( host_mock.use_https = TEST_USE_HTTPS host_mock.is_admin = True host_mock.user_level = "admin" + host_mock.stream_channels = [0] host_mock.sw_version_update_required = False host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" host_mock.manufacturer = "Reolink" host_mock.model = "RLC-123" + host_mock.camera_model.return_value = "RLC-123" + host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py new file mode 100644 index 00000000000..7fe3570564a --- /dev/null +++ b/tests/components/reolink/test_media_source.py @@ -0,0 +1,288 @@ +"""Tests for the Reolink media_source platform.""" +from datetime import datetime, timedelta +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.media_source import ( + DOMAIN as MEDIA_SOURCE_DOMAIN, + URI_SCHEME, + async_browse_media, + async_resolve_media, +) +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.reolink import const +from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL +from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.setup import async_setup_component + +from .conftest import ( + TEST_HOST2, + TEST_MAC2, + TEST_NVR_NAME, + TEST_NVR_NAME2, + TEST_PASSWORD2, + TEST_PORT, + TEST_USE_HTTPS, + TEST_USERNAME2, +) + +from tests.common import MockConfigEntry + +TEST_YEAR = 2023 +TEST_MONTH = 11 +TEST_DAY = 14 +TEST_DAY2 = 15 +TEST_HOUR = 13 +TEST_MINUTE = 12 +TEST_FILE_NAME = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00" +TEST_STREAM = "main" +TEST_CHANNEL = "0" + +TEST_MIME_TYPE = "application/x-mpegURL" +TEST_URL = "http:test_url" + + +@pytest.fixture(autouse=True) +async def setup_component(hass: HomeAssistant) -> None: + """Set up component.""" + assert await async_setup_component(hass, MEDIA_SOURCE_DOMAIN, {}) + assert await async_setup_component(hass, MEDIA_STREAM_DOMAIN, {}) + + +async def test_resolve( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test resolving Reolink media items.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) + caplog.set_level(logging.DEBUG) + + file_id = ( + f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" + ) + + play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{file_id}") + + assert play_media.mime_type == TEST_MIME_TYPE + + +async def test_browsing( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test browsing the Reolink three.""" + entry_id = config_entry.entry_id + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(entry_id) is True + await hass.async_block_till_done() + + entries = dr.async_entries_for_config_entry(device_registry, entry_id) + assert len(entries) > 0 + device_registry.async_update_device(entries[0].id, name_by_user="Cam new name") + + caplog.set_level(logging.DEBUG) + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}" + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert browse.children[0].identifier == browse_root_id + + # browse resolution select + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") + + browse_resolution_id = f"RESs|{entry_id}|{TEST_CHANNEL}" + browse_res_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|sub" + browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" + assert browse.domain == DOMAIN + assert browse.title == TEST_NVR_NAME + assert browse.identifier == browse_resolution_id + assert browse.children[0].identifier == browse_res_sub_id + assert browse.children[1].identifier == browse_res_main_id + + # browse camera recording days + mock_status = MagicMock() + mock_status.year = TEST_YEAR + mock_status.month = TEST_MONTH + mock_status.days = (TEST_DAY, TEST_DAY2) + reolink_connect.request_vod_files.return_value = ([mock_status], []) + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" + ) + + browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}" + browse_day_0_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" + browse_day_1_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} High res." + assert browse.identifier == browse_days_id + assert browse.children[0].identifier == browse_day_0_id + assert browse.children[1].identifier == browse_day_1_id + + # browse camera recording files on day + mock_vod_file = MagicMock() + mock_vod_file.start_time = datetime( + TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE + ) + mock_vod_file.duration = timedelta(minutes=15) + mock_vod_file.file_name = TEST_FILE_NAME + reolink_connect.request_vod_files.return_value = ([mock_status], [mock_vod_file]) + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") + + browse_files_id = f"FILES|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}" + browse_file_id = f"FILE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" + assert browse.domain == DOMAIN + assert ( + browse.title == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" + ) + assert browse.identifier == browse_files_id + assert browse.children[0].identifier == browse_file_id + + +async def test_browsing_unsupported_encoding( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera with unsupported stream encoding.""" + entry_id = config_entry.entry_id + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(entry_id) is True + await hass.async_block_till_done() + + browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}" + + # browse resolution select/camera recording days when main encoding unsupported + mock_status = MagicMock() + mock_status.year = TEST_YEAR + mock_status.month = TEST_MONTH + mock_status.days = (TEST_DAY, TEST_DAY2) + reolink_connect.request_vod_files.return_value = ([mock_status], []) + reolink_connect.time.return_value = None + reolink_connect.get_encoding.return_value = "h265" + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") + + browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|sub" + browse_day_0_id = ( + f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" + ) + browse_day_1_id = ( + f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" + ) + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} Low res." + assert browse.identifier == browse_days_id + assert browse.children[0].identifier == browse_day_0_id + assert browse.children[1].identifier == browse_day_1_id + + +async def test_browsing_rec_playback_unsupported( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera which does not support playback of recordings.""" + reolink_connect.api_version.return_value = 0 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert browse.children == [] + + +async def test_browsing_errors( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera errors.""" + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + # browse root + with pytest.raises(Unresolvable): + await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN") + with pytest.raises(Unresolvable): + await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN") + + +async def test_browsing_not_loaded( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera integration which is not loaded.""" + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + reolink_connect.get_host_data = AsyncMock(side_effect=ReolinkError("Test error")) + config_entry2 = MockConfigEntry( + domain=const.DOMAIN, + unique_id=format_mac(TEST_MAC2), + data={ + CONF_HOST: TEST_HOST2, + CONF_USERNAME: TEST_USERNAME2, + CONF_PASSWORD: TEST_PASSWORD2, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME2, + ) + config_entry2.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry2.entry_id) is False + await hass.async_block_till_done() + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert len(browse.children) == 1 From 464270d84955b89b433bbbb22c00258b183d1a95 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 21 Nov 2023 18:21:31 -0500 Subject: [PATCH 646/982] Add reauthentication to Roborock (#104215) * add reauth to roborock * update reauth based on comments * fix diagnostics? * Update homeassistant/components/roborock/config_flow.py Co-authored-by: Allen Porter * remove unneeded import * fix tests coverage --------- Co-authored-by: Allen Porter --- homeassistant/components/roborock/__init__.py | 10 ++- .../components/roborock/config_flow.py | 72 +++++++++++++++---- .../components/roborock/strings.json | 7 +- tests/components/roborock/test_config_flow.py | 47 ++++++++++-- tests/components/roborock/test_init.py | 29 ++++++++ 5 files changed, 143 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index b310b2bb2ba..eb9c4a1a066 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -5,6 +5,7 @@ import asyncio from datetime import timedelta import logging +from roborock import RoborockException, RoborockInvalidCredentials from roborock.api import RoborockApiClient from roborock.cloud_api import RoborockMqttClient from roborock.containers import DeviceData, HomeDataDevice, UserData @@ -12,7 +13,7 @@ from roborock.containers import DeviceData, HomeDataDevice, UserData from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import RoborockDataUpdateCoordinator @@ -29,7 +30,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) _LOGGER.debug("Getting home data") - home_data = await api_client.get_home_data(user_data) + try: + home_data = await api_client.get_home_data(user_data) + except RoborockInvalidCredentials as err: + raise ConfigEntryAuthFailed("Invalid credentials.") from err + except RoborockException as err: + raise ConfigEntryNotReady("Failed getting Roborock home_data.") from err _LOGGER.debug("Got home data %s", home_data) device_map: dict[str, HomeDataDevice] = { device.duid: device for device in home_data.devices + home_data.received_devices diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index fcfad6e8cd3..201631f0825 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Roborock.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -16,6 +17,7 @@ from roborock.exceptions import ( import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.data_entry_flow import FlowResult @@ -28,6 +30,7 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Roborock.""" VERSION = 1 + reauth_entry: ConfigEntry | None = None def __init__(self) -> None: """Initialize the config flow.""" @@ -47,21 +50,8 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._username = username _LOGGER.debug("Requesting code for Roborock account") self._client = RoborockApiClient(username) - try: - await self._client.request_code() - except RoborockAccountDoesNotExist: - errors["base"] = "invalid_email" - except RoborockUrlException: - errors["base"] = "unknown_url" - except RoborockInvalidEmail: - errors["base"] = "invalid_email_format" - except RoborockException as ex: - _LOGGER.exception(ex) - errors["base"] = "unknown_roborock" - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception(ex) - errors["base"] = "unknown" - else: + errors = await self._request_code() + if not errors: return await self.async_step_code() return self.async_show_form( step_id="user", @@ -69,6 +59,25 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def _request_code(self) -> dict: + assert self._client + errors: dict[str, str] = {} + try: + await self._client.request_code() + except RoborockAccountDoesNotExist: + errors["base"] = "invalid_email" + except RoborockUrlException: + errors["base"] = "unknown_url" + except RoborockInvalidEmail: + errors["base"] = "invalid_email_format" + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "unknown_roborock" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + return errors + async def async_step_code( self, user_input: dict[str, Any] | None = None, @@ -91,6 +100,18 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(ex) errors["base"] = "unknown" else: + if self.reauth_entry is not None: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_USER_DATA: login_data.as_dict(), + }, + ) + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") return self._create_entry(self._client, self._username, login_data) return self.async_show_form( @@ -99,6 +120,27 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._username = entry_data[CONF_USERNAME] + assert self._username + self._client = RoborockApiClient(self._username) + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await self._request_code() + if not errors: + return await self.async_step_code() + return self.async_show_form(step_id="reauth_confirm", errors=errors) + def _create_entry( self, client: RoborockApiClient, username: str, user_data: UserData ) -> FlowResult: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 8841741d4a1..67660816de7 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -12,6 +12,10 @@ "data": { "code": "Verification code" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Roborock integration needs to re-authenticate your account" } }, "error": { @@ -23,7 +27,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index bbaa8935461..e2454b3ad57 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -1,4 +1,5 @@ """Test Roborock config flow.""" +from copy import deepcopy from unittest.mock import patch import pytest @@ -12,9 +13,11 @@ from roborock.exceptions import ( from homeassistant import config_entries from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from ...common import MockConfigEntry from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL @@ -35,7 +38,7 @@ async def test_config_flow_success( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": USER_EMAIL} + result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) assert result["type"] == FlowResultType.FORM @@ -89,7 +92,7 @@ async def test_config_flow_failures_request_code( side_effect=request_code_side_effect, ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": USER_EMAIL} + result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) assert result["type"] == FlowResultType.FORM assert result["errors"] == request_code_errors @@ -98,7 +101,7 @@ async def test_config_flow_failures_request_code( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": USER_EMAIL} + result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) assert result["type"] == FlowResultType.FORM @@ -149,7 +152,7 @@ async def test_config_flow_failures_code_login( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": USER_EMAIL} + result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) assert result["type"] == FlowResultType.FORM @@ -178,3 +181,39 @@ async def test_config_flow_failures_code_login( assert result["data"] == MOCK_CONFIG assert result["result"] assert len(mock_setup.mock_calls) == 1 + + +async def test_reauth_flow( + hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry +) -> None: + """Test reauth flow.""" + # Start reauth + result = mock_roborock_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + # Request a new code + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + # Enter a new code + assert result["step_id"] == "code" + assert result["type"] == FlowResultType.FORM + new_user_data = deepcopy(USER_DATA) + new_user_data.rriot.s = "new_password_hash" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index a5ad24b431c..cdeaf03a3da 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,6 +1,8 @@ """Test for Roborock init.""" from unittest.mock import patch +from roborock import RoborockException, RoborockInvalidCredentials + from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -38,3 +40,30 @@ async def test_config_entry_not_ready( ): await async_setup_component(hass, DOMAIN, {}) assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_reauth_started( + hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry +) -> None: + """Test reauth flow started.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + side_effect=RoborockInvalidCredentials(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_config_entry_not_ready_home_data( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> None: + """Test that when we fail to get home data, entry retries.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY From f1fd8a0d2bb1d63f753c3677a2d9bc1d718cd325 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 22 Nov 2023 07:02:13 +0100 Subject: [PATCH 647/982] Bump aiounifi to v66 (#104336) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_controller.py | 5 ++++ tests/components/unifi/test_switch.py | 30 ++++++++++---------- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index ed8649896dd..52ed8ec3101 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==65"], + "requirements": ["aiounifi==66"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index f61995095d7..627b4e06eb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -374,7 +374,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==65 +aiounifi==66 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3cc46149751..b2607d2cad0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==65 +aiounifi==66 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 9d4bde2d016..d96b5d36d22 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -167,6 +167,11 @@ def mock_default_unifi_requests( json={"data": wlans_response or [], "meta": {"rc": "ok"}}, headers={"content-type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"https://{host}:1234/v2/api/site/{site_id}/trafficroutes", + json=[{}], + headers={"content-type": CONTENT_TYPE_JSON}, + ) aioclient_mock.get( f"https://{host}:1234/v2/api/site/{site_id}/trafficrules", json=[{}], diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index cfcfbe6c3ed..fe2ee5dc9e8 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -771,7 +771,6 @@ async def test_no_clients( }, ) - assert aioclient_mock.call_count == 12 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -852,7 +851,7 @@ async def test_switches( assert ent_reg.async_get(entry_id).entity_category is EntityCategory.CONFIG # Block and unblock client - + aioclient_mock.clear_requests() aioclient_mock.post( f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", ) @@ -860,8 +859,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -869,14 +868,14 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == { + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } # Enable and disable DPI - + aioclient_mock.clear_requests() aioclient_mock.put( f"https://{controller.host}:1234/api/s/{controller.site}/rest/dpiapp/5f976f62e3c58f018ec7e17d", ) @@ -887,8 +886,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 15 - assert aioclient_mock.mock_calls[14][2] == {"enabled": False} + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == {"enabled": False} await hass.services.async_call( SWITCH_DOMAIN, @@ -896,8 +895,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 16 - assert aioclient_mock.mock_calls[15][2] == {"enabled": True} + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == {"enabled": True} async def test_remove_switches( @@ -976,6 +975,7 @@ async def test_block_switches( assert blocked is not None assert blocked.state == "off" + aioclient_mock.clear_requests() aioclient_mock.post( f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", ) @@ -983,8 +983,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -992,8 +992,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == { + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } From edf18df0e60d477932057788e15f156e6cc63cd6 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 22 Nov 2023 07:02:49 +0100 Subject: [PATCH 648/982] Add PoE power cycle button to UniFi integration (#104332) --- homeassistant/components/unifi/button.py | 35 +++++++++- tests/components/unifi/test_button.py | 86 ++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 7471675123a..af7ab5852ab 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -11,8 +11,14 @@ from typing import Any, Generic import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.devices import Devices +from aiounifi.interfaces.ports import Ports from aiounifi.models.api import ApiItemT -from aiounifi.models.device import Device, DeviceRestartRequest +from aiounifi.models.device import ( + Device, + DevicePowerCyclePortRequest, + DeviceRestartRequest, +) +from aiounifi.models.port import Port from homeassistant.components.button import ( ButtonDeviceClass, @@ -42,6 +48,15 @@ async def async_restart_device_control_fn( await api.request(DeviceRestartRequest.create(obj_id)) +@callback +async def async_power_cycle_port_control_fn( + api: aiounifi.Controller, obj_id: str +) -> None: + """Restart device.""" + mac, _, index = obj_id.partition("_") + await api.request(DevicePowerCyclePortRequest.create(mac, int(index))) + + @dataclass class UnifiButtonEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -77,6 +92,24 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"device_restart-{obj_id}", ), + UnifiButtonEntityDescription[Ports, Port]( + key="PoE power cycle", + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + device_class=ButtonDeviceClass.RESTART, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.ports, + available_fn=async_device_available_fn, + control_fn=async_power_cycle_port_control_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda port: f"{port.name} Power Cycle", + object_fn=lambda api, obj_id: api.ports[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, + unique_id_fn=lambda controller, obj_id: f"power_cycle-{obj_id}", + ), ) diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 30a1b3e08ff..8e6dce71160 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -75,3 +75,89 @@ async def test_restart_device_button( # Controller reconnects await websocket_mock.reconnect() assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE + + +async def test_power_cycle_poe( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock +) -> None: + """Test restarting device button.""" + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + devices_response=[ + { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "switch", + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_caps": 7, + "poe_class": "Class 4", + "poe_enable": True, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": True, + "up": True, + }, + ], + } + ], + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("button.switch_port_1_power_cycle") + assert ent_reg_entry.unique_id == "power_cycle-00:00:00:00:01:01_1" + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Validate state object + button = hass.states.get("button.switch_port_1_power_cycle") + assert button is not None + assert button.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART + + # Send restart device command + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/devmgr", + ) + + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {"entity_id": "button.switch_port_1_power_cycle"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "cmd": "power-cycle", + "mac": "00:00:00:00:01:01", + "port_idx": 1, + } + + # Availability signalling + + # Controller disconnects + await websocket_mock.disconnect() + assert ( + hass.states.get("button.switch_port_1_power_cycle").state == STATE_UNAVAILABLE + ) + + # Controller reconnects + await websocket_mock.reconnect() + assert ( + hass.states.get("button.switch_port_1_power_cycle").state != STATE_UNAVAILABLE + ) From 3929b0163c5df2c66fb540549d34cb36d3abecd7 Mon Sep 17 00:00:00 2001 From: Vaarlion <59558433+Vaarlion@users.noreply.github.com> Date: Wed, 22 Nov 2023 08:15:26 +0100 Subject: [PATCH 649/982] Add RGB, RGBW and RGBWW capability to template.light (#86047) * Add RGB, RGBW and RGBWW capability to template.light Add the required unit test Mute 'LightTemplate.async_turn_on' is too complex Rename all HS color mode from a generic "Color" name to a specific "HS" name * Bring back legacy "color" keyword * Cleanup unrequested commented test * Increase code coverage to 100% * Remove confusing if that should never be false * Apply suggestions from code review --------- Co-authored-by: Erik Montnemery --- homeassistant/components/template/light.py | 411 +++++++++++++- tests/components/template/test_light.py | 591 ++++++++++++++++++++- 2 files changed, 952 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index b3f276240b5..89c4826f1e6 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -11,6 +11,9 @@ from homeassistant.components.light import ( ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_TRANSITION, ENTITY_ID_FORMAT, ColorMode, @@ -46,8 +49,18 @@ from .template_entity import ( _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] +# Legacy CONF_COLOR_ACTION = "set_color" CONF_COLOR_TEMPLATE = "color_template" + +CONF_HS_ACTION = "set_hs" +CONF_HS_TEMPLATE = "hs_template" +CONF_RGB_ACTION = "set_rgb" +CONF_RGB_TEMPLATE = "rgb_template" +CONF_RGBW_ACTION = "set_rgbw" +CONF_RGBW_TEMPLATE = "rgbw_template" +CONF_RGBWW_ACTION = "set_rgbww" +CONF_RGBWW_TEMPLATE = "rgbww_template" CONF_EFFECT_ACTION = "set_effect" CONF_EFFECT_LIST_TEMPLATE = "effect_list_template" CONF_EFFECT_TEMPLATE = "effect_template" @@ -67,8 +80,16 @@ LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { - vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_COLOR_TEMPLATE): cv.template, + vol.Exclusive(CONF_COLOR_ACTION, "hs_legacy_action"): cv.SCRIPT_SCHEMA, + vol.Exclusive(CONF_COLOR_TEMPLATE, "hs_legacy_template"): cv.template, + vol.Exclusive(CONF_HS_ACTION, "hs_legacy_action"): cv.SCRIPT_SCHEMA, + vol.Exclusive(CONF_HS_TEMPLATE, "hs_legacy_template"): cv.template, + vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGB_TEMPLATE): cv.template, + vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBW_TEMPLATE): cv.template, + vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBWW_TEMPLATE): cv.template, vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST_TEMPLATE, "effect"): cv.template, vol.Inclusive(CONF_EFFECT_TEMPLATE, "effect"): cv.template, @@ -166,6 +187,22 @@ class LightTemplate(TemplateEntity, LightEntity): if (color_action := config.get(CONF_COLOR_ACTION)) is not None: self._color_script = Script(hass, color_action, friendly_name, DOMAIN) self._color_template = config.get(CONF_COLOR_TEMPLATE) + self._hs_script = None + if (hs_action := config.get(CONF_HS_ACTION)) is not None: + self._hs_script = Script(hass, hs_action, friendly_name, DOMAIN) + self._hs_template = config.get(CONF_HS_TEMPLATE) + self._rgb_script = None + if (rgb_action := config.get(CONF_RGB_ACTION)) is not None: + self._rgb_script = Script(hass, rgb_action, friendly_name, DOMAIN) + self._rgb_template = config.get(CONF_RGB_TEMPLATE) + self._rgbw_script = None + if (rgbw_action := config.get(CONF_RGBW_ACTION)) is not None: + self._rgbw_script = Script(hass, rgbw_action, friendly_name, DOMAIN) + self._rgbw_template = config.get(CONF_RGBW_TEMPLATE) + self._rgbww_script = None + if (rgbww_action := config.get(CONF_RGBWW_ACTION)) is not None: + self._rgbww_script = Script(hass, rgbww_action, friendly_name, DOMAIN) + self._rgbww_template = config.get(CONF_RGBWW_TEMPLATE) self._effect_script = None if (effect_action := config.get(CONF_EFFECT_ACTION)) is not None: self._effect_script = Script(hass, effect_action, friendly_name, DOMAIN) @@ -178,24 +215,39 @@ class LightTemplate(TemplateEntity, LightEntity): self._state = False self._brightness = None self._temperature = None - self._color = None + self._hs_color = None + self._rgb_color = None + self._rgbw_color = None + self._rgbww_color = None self._effect = None self._effect_list = None - self._fixed_color_mode = None + self._color_mode = None self._max_mireds = None self._min_mireds = None self._supports_transition = False + self._supported_color_modes = None color_modes = {ColorMode.ONOFF} if self._level_script is not None: color_modes.add(ColorMode.BRIGHTNESS) if self._temperature_script is not None: color_modes.add(ColorMode.COLOR_TEMP) + if self._hs_script is not None: + color_modes.add(ColorMode.HS) if self._color_script is not None: color_modes.add(ColorMode.HS) + if self._rgb_script is not None: + color_modes.add(ColorMode.RGB) + if self._rgbw_script is not None: + color_modes.add(ColorMode.RGBW) + if self._rgbww_script is not None: + color_modes.add(ColorMode.RGBWW) + self._supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._supported_color_modes) > 1: + self._color_mode = ColorMode.UNKNOWN if len(self._supported_color_modes) == 1: - self._fixed_color_mode = next(iter(self._supported_color_modes)) + self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) if self._effect_script is not None: @@ -232,7 +284,22 @@ class LightTemplate(TemplateEntity, LightEntity): @property def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" - return self._color + return self._hs_color + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color value.""" + return self._rgb_color + + @property + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Return the rgbw color value.""" + return self._rgbw_color + + @property + def rgbww_color(self) -> tuple[int, int, int, int, int] | None: + """Return the rgbww color value.""" + return self._rgbww_color @property def effect(self) -> str | None: @@ -247,12 +314,7 @@ class LightTemplate(TemplateEntity, LightEntity): @property def color_mode(self): """Return current color mode.""" - if self._fixed_color_mode: - return self._fixed_color_mode - # Support for ct + hs, prioritize hs - if self._color is not None: - return ColorMode.HS - return ColorMode.COLOR_TEMP + return self._color_mode @property def supported_color_modes(self): @@ -305,10 +367,42 @@ class LightTemplate(TemplateEntity, LightEntity): ) if self._color_template: self.add_template_attribute( - "_color", + "_hs_color", self._color_template, None, - self._update_color, + self._update_hs, + none_on_template_error=True, + ) + if self._hs_template: + self.add_template_attribute( + "_hs_color", + self._hs_template, + None, + self._update_hs, + none_on_template_error=True, + ) + if self._rgb_template: + self.add_template_attribute( + "_rgb_color", + self._rgb_template, + None, + self._update_rgb, + none_on_template_error=True, + ) + if self._rgbw_template: + self.add_template_attribute( + "_rgbw_color", + self._rgbw_template, + None, + self._update_rgbw, + none_on_template_error=True, + ) + if self._rgbww_template: + self.add_template_attribute( + "_rgbww_color", + self._rgbww_template, + None, + self._update_rgbww, none_on_template_error=True, ) if self._effect_list_template: @@ -337,7 +431,7 @@ class LightTemplate(TemplateEntity, LightEntity): ) super()._async_setup_templates() - async def async_turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 """Turn the light on.""" optimistic_set = False # set optimistic states @@ -357,19 +451,88 @@ class LightTemplate(TemplateEntity, LightEntity): "Optimistically setting color temperature to %s", kwargs[ATTR_COLOR_TEMP], ) + self._color_mode = ColorMode.COLOR_TEMP self._temperature = kwargs[ATTR_COLOR_TEMP] - if self._color_template is None: - self._color = None + if self._hs_template is None and self._color_template is None: + self._hs_color = None + if self._rgb_template is None: + self._rgb_color = None + if self._rgbw_template is None: + self._rgbw_color = None + if self._rgbww_template is None: + self._rgbww_color = None optimistic_set = True - if self._color_template is None and ATTR_HS_COLOR in kwargs: + if ( + self._hs_template is None + and self._color_template is None + and ATTR_HS_COLOR in kwargs + ): _LOGGER.debug( - "Optimistically setting color to %s", + "Optimistically setting hs color to %s", kwargs[ATTR_HS_COLOR], ) - self._color = kwargs[ATTR_HS_COLOR] + self._color_mode = ColorMode.HS + self._hs_color = kwargs[ATTR_HS_COLOR] if self._temperature_template is None: self._temperature = None + if self._rgb_template is None: + self._rgb_color = None + if self._rgbw_template is None: + self._rgbw_color = None + if self._rgbww_template is None: + self._rgbww_color = None + optimistic_set = True + + if self._rgb_template is None and ATTR_RGB_COLOR in kwargs: + _LOGGER.debug( + "Optimistically setting rgb color to %s", + kwargs[ATTR_RGB_COLOR], + ) + self._color_mode = ColorMode.RGB + self._rgb_color = kwargs[ATTR_RGB_COLOR] + if self._temperature_template is None: + self._temperature = None + if self._hs_template is None and self._color_template is None: + self._hs_color = None + if self._rgbw_template is None: + self._rgbw_color = None + if self._rgbww_template is None: + self._rgbww_color = None + optimistic_set = True + + if self._rgbw_template is None and ATTR_RGBW_COLOR in kwargs: + _LOGGER.debug( + "Optimistically setting rgbw color to %s", + kwargs[ATTR_RGBW_COLOR], + ) + self._color_mode = ColorMode.RGBW + self._rgbw_color = kwargs[ATTR_RGBW_COLOR] + if self._temperature_template is None: + self._temperature = None + if self._hs_template is None and self._color_template is None: + self._hs_color = None + if self._rgb_template is None: + self._rgb_color = None + if self._rgbww_template is None: + self._rgbww_color = None + optimistic_set = True + + if self._rgbww_template is None and ATTR_RGBWW_COLOR in kwargs: + _LOGGER.debug( + "Optimistically setting rgbww color to %s", + kwargs[ATTR_RGBWW_COLOR], + ) + self._color_mode = ColorMode.RGBWW + self._rgbww_color = kwargs[ATTR_RGBWW_COLOR] + if self._temperature_template is None: + self._temperature = None + if self._hs_template is None and self._color_template is None: + self._hs_color = None + if self._rgb_template is None: + self._rgb_color = None + if self._rgbw_template is None: + self._rgbw_color = None optimistic_set = True common_params = {} @@ -413,6 +576,58 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( self._color_script, run_variables=common_params, context=self._context ) + elif ATTR_HS_COLOR in kwargs and self._hs_script: + hs_value = kwargs[ATTR_HS_COLOR] + common_params["hs"] = hs_value + common_params["h"] = int(hs_value[0]) + common_params["s"] = int(hs_value[1]) + + await self.async_run_script( + self._hs_script, run_variables=common_params, context=self._context + ) + elif ATTR_RGBWW_COLOR in kwargs and self._rgbww_script: + rgbww_value = kwargs[ATTR_RGBWW_COLOR] + common_params["rgbww"] = rgbww_value + common_params["rgb"] = ( + int(rgbww_value[0]), + int(rgbww_value[1]), + int(rgbww_value[2]), + ) + common_params["r"] = int(rgbww_value[0]) + common_params["g"] = int(rgbww_value[1]) + common_params["b"] = int(rgbww_value[2]) + common_params["cw"] = int(rgbww_value[3]) + common_params["ww"] = int(rgbww_value[4]) + + await self.async_run_script( + self._rgbww_script, run_variables=common_params, context=self._context + ) + elif ATTR_RGBW_COLOR in kwargs and self._rgbw_script: + rgbw_value = kwargs[ATTR_RGBW_COLOR] + common_params["rgbw"] = rgbw_value + common_params["rgb"] = ( + int(rgbw_value[0]), + int(rgbw_value[1]), + int(rgbw_value[2]), + ) + common_params["r"] = int(rgbw_value[0]) + common_params["g"] = int(rgbw_value[1]) + common_params["b"] = int(rgbw_value[2]) + common_params["w"] = int(rgbw_value[3]) + + await self.async_run_script( + self._rgbw_script, run_variables=common_params, context=self._context + ) + elif ATTR_RGB_COLOR in kwargs and self._rgb_script: + rgb_value = kwargs[ATTR_RGB_COLOR] + common_params["rgb"] = rgb_value + common_params["r"] = int(rgb_value[0]) + common_params["g"] = int(rgb_value[1]) + common_params["b"] = int(rgb_value[2]) + + await self.async_run_script( + self._rgb_script, run_variables=common_params, context=self._context + ) elif ATTR_BRIGHTNESS in kwargs and self._level_script: await self.async_run_script( self._level_script, run_variables=common_params, context=self._context @@ -560,18 +775,19 @@ class LightTemplate(TemplateEntity, LightEntity): " this light, or 'None'" ) self._temperature = None + self._color_mode = ColorMode.COLOR_TEMP @callback - def _update_color(self, render): - """Update the hs_color from the template.""" + def _update_hs(self, render): + """Update the color from the template.""" if render is None: - self._color = None + self._hs_color = None return h_str = s_str = None if isinstance(render, str): if render in ("None", ""): - self._color = None + self._hs_color = None return h_str, s_str = map( float, render.replace("(", "").replace(")", "").split(",", 1) @@ -582,10 +798,12 @@ class LightTemplate(TemplateEntity, LightEntity): if ( h_str is not None and s_str is not None + and isinstance(h_str, (int, float)) + and isinstance(s_str, (int, float)) and 0 <= h_str <= 360 and 0 <= s_str <= 100 ): - self._color = (h_str, s_str) + self._hs_color = (h_str, s_str) elif h_str is not None and s_str is not None: _LOGGER.error( ( @@ -596,12 +814,151 @@ class LightTemplate(TemplateEntity, LightEntity): s_str, self.entity_id, ) - self._color = None + self._hs_color = None else: _LOGGER.error( "Received invalid hs_color : (%s) for entity %s", render, self.entity_id ) - self._color = None + self._hs_color = None + self._color_mode = ColorMode.HS + + @callback + def _update_rgb(self, render): + """Update the color from the template.""" + if render is None: + self._rgb_color = None + return + + r_int = g_int = b_int = None + if isinstance(render, str): + if render in ("None", ""): + self._rgb_color = None + return + cleanup_char = ["(", ")", "[", "]", " "] + for char in cleanup_char: + render = render.replace(char, "") + r_int, g_int, b_int = map(int, render.split(",", 3)) + elif isinstance(render, (list, tuple)) and len(render) == 3: + r_int, g_int, b_int = render + + if all( + value is not None and isinstance(value, (int, float)) and 0 <= value <= 255 + for value in (r_int, g_int, b_int) + ): + self._rgb_color = (r_int, g_int, b_int) + elif any( + isinstance(value, (int, float)) and not 0 <= value <= 255 + for value in (r_int, g_int, b_int) + ): + _LOGGER.error( + "Received invalid rgb_color : (%s, %s, %s) for entity %s. Expected: (0-255, 0-255, 0-255)", + r_int, + g_int, + b_int, + self.entity_id, + ) + self._rgb_color = None + else: + _LOGGER.error( + "Received invalid rgb_color : (%s) for entity %s", + render, + self.entity_id, + ) + self._rgb_color = None + self._color_mode = ColorMode.RGB + + @callback + def _update_rgbw(self, render): + """Update the color from the template.""" + if render is None: + self._rgbw_color = None + return + + r_int = g_int = b_int = w_int = None + if isinstance(render, str): + if render in ("None", ""): + self._rgb_color = None + return + cleanup_char = ["(", ")", "[", "]", " "] + for char in cleanup_char: + render = render.replace(char, "") + r_int, g_int, b_int, w_int = map(int, render.split(",", 4)) + elif isinstance(render, (list, tuple)) and len(render) == 4: + r_int, g_int, b_int, w_int = render + + if all( + value is not None and isinstance(value, (int, float)) and 0 <= value <= 255 + for value in (r_int, g_int, b_int, w_int) + ): + self._rgbw_color = (r_int, g_int, b_int, w_int) + elif any( + isinstance(value, (int, float)) and not 0 <= value <= 255 + for value in (r_int, g_int, b_int, w_int) + ): + _LOGGER.error( + "Received invalid rgb_color : (%s, %s, %s, %s) for entity %s. Expected: (0-255, 0-255, 0-255, 0-255)", + r_int, + g_int, + b_int, + w_int, + self.entity_id, + ) + self._rgbw_color = None + else: + _LOGGER.error( + "Received invalid rgb_color : (%s) for entity %s", + render, + self.entity_id, + ) + self._rgbw_color = None + self._color_mode = ColorMode.RGBW + + @callback + def _update_rgbww(self, render): + """Update the color from the template.""" + if render is None: + self._rgbww_color = None + return + + r_int = g_int = b_int = cw_int = ww_int = None + if isinstance(render, str): + if render in ("None", ""): + self._rgb_color = None + return + cleanup_char = ["(", ")", "[", "]", " "] + for char in cleanup_char: + render = render.replace(char, "") + r_int, g_int, b_int, cw_int, ww_int = map(int, render.split(",", 5)) + elif isinstance(render, (list, tuple)) and len(render) == 5: + r_int, g_int, b_int, cw_int, ww_int = render + + if all( + value is not None and isinstance(value, (int, float)) and 0 <= value <= 255 + for value in (r_int, g_int, b_int, cw_int, ww_int) + ): + self._rgbww_color = (r_int, g_int, b_int, cw_int, ww_int) + elif any( + isinstance(value, (int, float)) and not 0 <= value <= 255 + for value in (r_int, g_int, b_int, cw_int, ww_int) + ): + _LOGGER.error( + "Received invalid rgb_color : (%s, %s, %s, %s, %s) for entity %s. Expected: (0-255, 0-255, 0-255, 0-255)", + r_int, + g_int, + b_int, + cw_int, + ww_int, + self.entity_id, + ) + self._rgbww_color = None + else: + _LOGGER.error( + "Received invalid rgb_color : (%s) for entity %s", + render, + self.entity_id, + ) + self._rgbww_color = None + self._color_mode = ColorMode.RGBWW @callback def _update_max_mireds(self, render): diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index f807b185c45..ec830d4daf6 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -7,6 +7,9 @@ from homeassistant.components.light import ( ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_TRANSITION, ColorMode, LightEntityFeature, @@ -72,7 +75,7 @@ OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG = { } -OPTIMISTIC_HS_COLOR_LIGHT_CONFIG = { +OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG = { **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, "set_color": { "service": "test.automation", @@ -86,6 +89,68 @@ OPTIMISTIC_HS_COLOR_LIGHT_CONFIG = { } +OPTIMISTIC_HS_COLOR_LIGHT_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "set_hs": { + "service": "test.automation", + "data_template": { + "action": "set_hs", + "caller": "{{ this.entity_id }}", + "s": "{{s}}", + "h": "{{h}}", + }, + }, +} + + +OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "set_rgb": { + "service": "test.automation", + "data_template": { + "action": "set_rgb", + "caller": "{{ this.entity_id }}", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + }, + }, +} + + +OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "set_rgbw": { + "service": "test.automation", + "data_template": { + "action": "set_rgbw", + "caller": "{{ this.entity_id }}", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "w": "{{w}}", + }, + }, +} + + +OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "set_rgbww": { + "service": "test.automation", + "data_template": { + "action": "set_rgbww", + "caller": "{{ this.entity_id }}", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "cw": "{{cw}}", + "ww": "{{ww}}", + }, + }, +} + + async def async_setup_light(hass, count, light_config): """Do setup of light integration.""" config = {"light": {"platform": "template", "lights": light_config}} @@ -607,6 +672,7 @@ async def test_level_action_no_template( "{{ state_attr('light.nolight', 'brightness') }}", ColorMode.BRIGHTNESS, ), + (None, "{{'one'}}", ColorMode.BRIGHTNESS), ], ) async def test_level_template( @@ -643,6 +709,7 @@ async def test_level_template( (None, "None", ColorMode.COLOR_TEMP), (None, "{{ none }}", ColorMode.COLOR_TEMP), (None, "", ColorMode.COLOR_TEMP), + (None, "{{ 'one' }}", ColorMode.COLOR_TEMP), ], ) async def test_temperature_template( @@ -797,17 +864,17 @@ async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None [ { "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, + **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, "value_template": "{{1 == 1}}", } }, ], ) -async def test_color_action_no_template( - hass: HomeAssistant, +async def test_legacy_color_action_no_template( + hass, setup_light, calls, -) -> None: +): """Test setting color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -833,6 +900,186 @@ async def test_color_action_no_template( assert state.attributes["supported_features"] == 0 +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_hs_color_action_no_template( + hass: HomeAssistant, + setup_light, + calls, +) -> None: + """Test setting hs color with optimistic template.""" + state = hass.states.get("light.test_template_light") + assert state.attributes.get("hs_color") is None + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_HS_COLOR: (40, 50)}, + blocking=True, + ) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_hs" + assert calls[-1].data["caller"] == "light.test_template_light" + assert calls[-1].data["h"] == 40 + assert calls[-1].data["s"] == 50 + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + assert state.attributes["color_mode"] == ColorMode.HS + assert state.attributes.get("hs_color") == (40, 50) + assert state.attributes["supported_color_modes"] == [ColorMode.HS] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_rgb_color_action_no_template( + hass: HomeAssistant, + setup_light, + calls, +) -> None: + """Test setting rgb color with optimistic template.""" + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgb_color") is None + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_RGB_COLOR: (160, 78, 192)}, + blocking=True, + ) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_rgb" + assert calls[-1].data["caller"] == "light.test_template_light" + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + assert state.attributes["color_mode"] == ColorMode.RGB + assert state.attributes.get("rgb_color") == (160, 78, 192) + assert state.attributes["supported_color_modes"] == [ColorMode.RGB] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_rgbw_color_action_no_template( + hass: HomeAssistant, + setup_light, + calls, +) -> None: + """Test setting rgbw color with optimistic template.""" + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgbw_color") is None + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_template_light", + ATTR_RGBW_COLOR: (160, 78, 192, 25), + }, + blocking=True, + ) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_rgbw" + assert calls[-1].data["caller"] == "light.test_template_light" + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + assert calls[-1].data["w"] == 25 + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + assert state.attributes["color_mode"] == ColorMode.RGBW + assert state.attributes.get("rgbw_color") == (160, 78, 192, 25) + assert state.attributes["supported_color_modes"] == [ColorMode.RGBW] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_rgbww_color_action_no_template( + hass: HomeAssistant, + setup_light, + calls, +) -> None: + """Test setting rgbww color with optimistic template.""" + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgbww_color") is None + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_template_light", + ATTR_RGBWW_COLOR: (160, 78, 192, 25, 55), + }, + blocking=True, + ) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_rgbww" + assert calls[-1].data["caller"] == "light.test_template_light" + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + assert calls[-1].data["cw"] == 25 + assert calls[-1].data["ww"] == 55 + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + assert state.attributes["color_mode"] == ColorMode.RGBWW + assert state.attributes.get("rgbww_color") == (160, 78, 192, 25, 55) + assert state.attributes["supported_color_modes"] == [ColorMode.RGBWW] + assert state.attributes["supported_features"] == 0 + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("expected_hs", "color_template", "expected_color_mode"), @@ -845,19 +1092,20 @@ async def test_color_action_no_template( (None, "{{x - 12}}", ColorMode.HS), (None, "", ColorMode.HS), (None, "{{ none }}", ColorMode.HS), + (None, "{{('one','two')}}", ColorMode.HS), ], ) -async def test_color_template( - hass: HomeAssistant, +async def test_legacy_color_template( + hass, expected_hs, expected_color_mode, count, color_template, -) -> None: +): """Test the template for the color.""" light_config = { "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, + **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, "value_template": "{{ 1 == 1 }}", "color_template": color_template, } @@ -871,6 +1119,176 @@ async def test_color_template( assert state.attributes["supported_features"] == 0 +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected_hs", "hs_template", "expected_color_mode"), + [ + ((360, 100), "{{(360, 100)}}", ColorMode.HS), + ((360, 100), "(360, 100)", ColorMode.HS), + ((359.9, 99.9), "{{(359.9, 99.9)}}", ColorMode.HS), + (None, "{{(361, 100)}}", ColorMode.HS), + (None, "{{(360, 101)}}", ColorMode.HS), + (None, "[{{(360)}},{{null}}]", ColorMode.HS), + (None, "{{x - 12}}", ColorMode.HS), + (None, "", ColorMode.HS), + (None, "{{ none }}", ColorMode.HS), + (None, "{{('one','two')}}", ColorMode.HS), + ], +) +async def test_hs_template( + hass: HomeAssistant, + expected_hs, + expected_color_mode, + count, + hs_template, +) -> None: + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "hs_template": hs_template, + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state.attributes.get("hs_color") == expected_hs + assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes["supported_color_modes"] == [ColorMode.HS] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected_rgb", "rgb_template", "expected_color_mode"), + [ + ((160, 78, 192), "{{(160, 78, 192)}}", ColorMode.RGB), + ((160, 78, 192), "{{[160, 78, 192]}}", ColorMode.RGB), + ((160, 78, 192), "(160, 78, 192)", ColorMode.RGB), + ((159, 77, 191), "{{(159.9, 77.9, 191.9)}}", ColorMode.RGB), + (None, "{{(256, 100, 100)}}", ColorMode.RGB), + (None, "{{(100, 256, 100)}}", ColorMode.RGB), + (None, "{{(100, 100, 256)}}", ColorMode.RGB), + (None, "{{x - 12}}", ColorMode.RGB), + (None, "", ColorMode.RGB), + (None, "{{ none }}", ColorMode.RGB), + (None, "{{('one','two','tree')}}", ColorMode.RGB), + ], +) +async def test_rgb_template( + hass: HomeAssistant, + expected_rgb, + expected_color_mode, + count, + rgb_template, +) -> None: + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "rgb_template": rgb_template, + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgb_color") == expected_rgb + assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes["supported_color_modes"] == [ColorMode.RGB] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected_rgbw", "rgbw_template", "expected_color_mode"), + [ + ((160, 78, 192, 25), "{{(160, 78, 192, 25)}}", ColorMode.RGBW), + ((160, 78, 192, 25), "{{[160, 78, 192, 25]}}", ColorMode.RGBW), + ((160, 78, 192, 25), "(160, 78, 192, 25)", ColorMode.RGBW), + ((159, 77, 191, 24), "{{(159.9, 77.9, 191.9, 24.9)}}", ColorMode.RGBW), + (None, "{{(256, 100, 100, 100)}}", ColorMode.RGBW), + (None, "{{(100, 256, 100, 100)}}", ColorMode.RGBW), + (None, "{{(100, 100, 256, 100)}}", ColorMode.RGBW), + (None, "{{(100, 100, 100, 256)}}", ColorMode.RGBW), + (None, "{{x - 12}}", ColorMode.RGBW), + (None, "", ColorMode.RGBW), + (None, "{{ none }}", ColorMode.RGBW), + (None, "{{('one','two','tree','four')}}", ColorMode.RGBW), + ], +) +async def test_rgbw_template( + hass: HomeAssistant, + expected_rgbw, + expected_color_mode, + count, + rgbw_template, +) -> None: + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "rgbw_template": rgbw_template, + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgbw_color") == expected_rgbw + assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes["supported_color_modes"] == [ColorMode.RGBW] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected_rgbww", "rgbww_template", "expected_color_mode"), + [ + ((160, 78, 192, 25, 55), "{{(160, 78, 192, 25, 55)}}", ColorMode.RGBWW), + ((160, 78, 192, 25, 55), "(160, 78, 192, 25, 55)", ColorMode.RGBWW), + ((160, 78, 192, 25, 55), "{{[160, 78, 192, 25, 55]}}", ColorMode.RGBWW), + ( + (159, 77, 191, 24, 54), + "{{(159.9, 77.9, 191.9, 24.9, 54.9)}}", + ColorMode.RGBWW, + ), + (None, "{{(256, 100, 100, 100, 100)}}", ColorMode.RGBWW), + (None, "{{(100, 256, 100, 100, 100)}}", ColorMode.RGBWW), + (None, "{{(100, 100, 256, 100, 100)}}", ColorMode.RGBWW), + (None, "{{(100, 100, 100, 256, 100)}}", ColorMode.RGBWW), + (None, "{{(100, 100, 100, 100, 256)}}", ColorMode.RGBWW), + (None, "{{x - 12}}", ColorMode.RGBWW), + (None, "", ColorMode.RGBWW), + (None, "{{ none }}", ColorMode.RGBWW), + (None, "{{('one','two','tree','four','five')}}", ColorMode.RGBWW), + ], +) +async def test_rgbww_template( + hass: HomeAssistant, + expected_rgbww, + expected_color_mode, + count, + rgbww_template, +) -> None: + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "rgbww_template": rgbww_template, + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgbww_color") == expected_rgbww + assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes["supported_color_modes"] == [ColorMode.RGBWW] + assert state.attributes["supported_features"] == 0 + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( "light_config", @@ -879,16 +1297,14 @@ async def test_color_template( "test_template_light": { **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, "value_template": "{{1 == 1}}", - "set_color": [ - { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "h": "{{h}}", - "s": "{{s}}", - }, + "set_hs": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "h": "{{h}}", + "s": "{{s}}", }, - ], + }, "set_temperature": { "service": "test.automation", "data_template": { @@ -896,18 +1312,48 @@ async def test_color_template( "color_temp": "{{color_temp}}", }, }, + "set_rgb": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + }, + }, + "set_rgbw": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "w": "{{w}}", + }, + }, + "set_rgbww": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "cw": "{{cw}}", + "ww": "{{ww}}", + }, + }, } }, ], ) -async def test_color_and_temperature_actions_no_template( +async def test_all_colors_mode_no_template( hass: HomeAssistant, setup_light, calls ) -> None: """Test setting color and color temperature with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None - # Optimistically set color, light should be in hs_color mode + # Optimistically set hs color, light should be in hs_color mode await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, @@ -926,6 +1372,9 @@ async def test_color_and_temperature_actions_no_template( assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, ] assert state.attributes["supported_features"] == 0 @@ -947,10 +1396,100 @@ async def test_color_and_temperature_actions_no_template( assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, ] assert state.attributes["supported_features"] == 0 - # Optimistically set color, light should again be in hs_color mode + # Optimistically set rgb color, light should be in rgb_color mode + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_RGB_COLOR: (160, 78, 192)}, + blocking=True, + ) + + assert len(calls) == 3 + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + + state = hass.states.get("light.test_template_light") + assert state.attributes["color_mode"] == ColorMode.RGB + assert state.attributes["color_temp"] is None + assert state.attributes["rgb_color"] == (160, 78, 192) + assert state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ] + assert state.attributes["supported_features"] == 0 + + # Optimistically set rgbw color, light should be in rgb_color mode + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_template_light", + ATTR_RGBW_COLOR: (160, 78, 192, 25), + }, + blocking=True, + ) + + assert len(calls) == 4 + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + assert calls[-1].data["w"] == 25 + + state = hass.states.get("light.test_template_light") + assert state.attributes["color_mode"] == ColorMode.RGBW + assert state.attributes["color_temp"] is None + assert state.attributes["rgbw_color"] == (160, 78, 192, 25) + assert state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ] + assert state.attributes["supported_features"] == 0 + + # Optimistically set rgbww color, light should be in rgb_color mode + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_template_light", + ATTR_RGBWW_COLOR: (160, 78, 192, 25, 55), + }, + blocking=True, + ) + + assert len(calls) == 5 + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + assert calls[-1].data["cw"] == 25 + assert calls[-1].data["ww"] == 55 + + state = hass.states.get("light.test_template_light") + assert state.attributes["color_mode"] == ColorMode.RGBWW + assert state.attributes["color_temp"] is None + assert state.attributes["rgbww_color"] == (160, 78, 192, 25, 55) + assert state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ] + assert state.attributes["supported_features"] == 0 + + # Optimistically set hs color, light should again be in hs_color mode await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, @@ -958,7 +1497,7 @@ async def test_color_and_temperature_actions_no_template( blocking=True, ) - assert len(calls) == 3 + assert len(calls) == 6 assert calls[-1].data["h"] == 10 assert calls[-1].data["s"] == 20 @@ -969,6 +1508,9 @@ async def test_color_and_temperature_actions_no_template( assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, ] assert state.attributes["supported_features"] == 0 @@ -980,7 +1522,7 @@ async def test_color_and_temperature_actions_no_template( blocking=True, ) - assert len(calls) == 4 + assert len(calls) == 7 assert calls[-1].data["color_temp"] == 234 state = hass.states.get("light.test_template_light") @@ -990,6 +1532,9 @@ async def test_color_and_temperature_actions_no_template( assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, ] assert state.attributes["supported_features"] == 0 From af15aab35e45f43025c45b1d280f4d043c82ed35 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 22 Nov 2023 08:40:19 +0100 Subject: [PATCH 650/982] Add Picnic shopping cart as Todo list (#102855) Co-authored-by: Robert Resch Co-authored-by: Allen Porter --- homeassistant/components/picnic/__init__.py | 2 +- homeassistant/components/picnic/strings.json | 5 + homeassistant/components/picnic/todo.py | 75 ++++ tests/components/picnic/conftest.py | 52 +++ tests/components/picnic/fixtures/cart.json | 337 ++++++++++++++++++ .../components/picnic/fixtures/delivery.json | 31 ++ tests/components/picnic/fixtures/user.json | 14 + tests/components/picnic/test_todo.py | 54 +++ 8 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/picnic/todo.py create mode 100644 tests/components/picnic/conftest.py create mode 100644 tests/components/picnic/fixtures/cart.json create mode 100644 tests/components/picnic/fixtures/delivery.json create mode 100644 tests/components/picnic/fixtures/user.json create mode 100644 tests/components/picnic/test_todo.py diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index ec7f6e15425..6826d8940ab 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -10,7 +10,7 @@ from .const import CONF_API, CONF_COORDINATOR, CONF_COUNTRY_CODE, DOMAIN from .coordinator import PicnicUpdateCoordinator from .services import async_register_services -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.TODO] def create_picnic_client(entry: ConfigEntry): diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index 0fd107609d1..9a6b7162fd5 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -21,6 +21,11 @@ } }, "entity": { + "todo": { + "shopping_cart": { + "name": "Shopping cart" + } + }, "sensor": { "cart_items_count": { "name": "Cart items count" diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py new file mode 100644 index 00000000000..8210702e826 --- /dev/null +++ b/homeassistant/components/picnic/todo.py @@ -0,0 +1,75 @@ +"""Definition of Picnic shopping cart.""" +from __future__ import annotations + +import logging +from typing import Any, cast + +from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import CONF_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Picnic shopping cart todo platform config entry.""" + picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + + # Add an entity shopping card + async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) + + +class PicnicCart(TodoListEntity, CoordinatorEntity): + """A Picnic Shopping Cart TodoListEntity.""" + + _attr_has_entity_name = True + _attr_translation_key = "shopping_cart" + _attr_icon = "mdi:cart" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Any], + config_entry: ConfigEntry, + ) -> None: + """Initialize PicnicCart.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, cast(str, config_entry.unique_id))}, + manufacturer="Picnic", + model=config_entry.unique_id, + ) + self._attr_unique_id = f"{config_entry.unique_id}-cart" + + @property + def todo_items(self) -> list[TodoItem] | None: + """Get the current set of items in cart items.""" + if self.coordinator.data is None: + return None + + _LOGGER.debug(self.coordinator.data["cart_data"]["items"]) + + items = [] + for item in self.coordinator.data["cart_data"]["items"]: + for article in item["items"]: + items.append( + TodoItem( + summary=f"{article['name']} ({article['unit_quantity']})", + uid=f"{item['id']}-{article['id']}", + status=TodoItemStatus.NEEDS_ACTION, # We set 'NEEDS_ACTION' so they count as state + ) + ) + + return items diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py new file mode 100644 index 00000000000..7e36371767d --- /dev/null +++ b/tests/components/picnic/conftest.py @@ -0,0 +1,52 @@ +"""Conftest for Picnic tests.""" +import json +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.picnic import CONF_COUNTRY_CODE, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ACCESS_TOKEN: "x-original-picnic-auth-token", + CONF_COUNTRY_CODE: "NL", + }, + unique_id="295-6y3-1nf4", + ) + + +@pytest.fixture +def mock_picnic_api(): + """Return a mocked PicnicAPI client.""" + with patch("homeassistant.components.picnic.PicnicAPI") as mock: + client = mock.return_value + client.session.auth_token = "3q29fpwhulzes" + client.get_cart.return_value = json.loads(load_fixture("picnic/cart.json")) + client.get_user.return_value = json.loads(load_fixture("picnic/user.json")) + client.get_deliveries.return_value = json.loads( + load_fixture("picnic/delivery.json") + ) + client.get_delivery_position.return_value = {} + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_picnic_api: MagicMock +) -> MockConfigEntry: + """Set up the Picnic integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/picnic/fixtures/cart.json b/tests/components/picnic/fixtures/cart.json new file mode 100644 index 00000000000..bde170bb26a --- /dev/null +++ b/tests/components/picnic/fixtures/cart.json @@ -0,0 +1,337 @@ +{ + "items": [ + { + "type": "ORDER_LINE", + "id": "763", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1001194", + "name": "Knoflook", + "image_ids": [ + "4054013cb82da80abbdcd7c8eec54f486bfa180b9cf499e94cc4013470d0dfd7" + ], + "unit_quantity": "2 stuks", + "unit_quantity_sub": "€9.08/kg", + "price": 109, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "2 stuks" + } + ] + } + ], + "display_price": 109, + "price": 109 + }, + { + "type": "ORDER_LINE", + "id": "765_766", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1046297", + "name": "Picnic magere melk", + "image_ids": [ + "c2a96757634ada380726d3307e564f244cfa86e89d94c2c0e382306dbad599a3" + ], + "unit_quantity": "2 x 1 liter", + "unit_quantity_sub": "€1.02/l", + "price": 204, + "max_count": 18, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 2 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "2 x 1 liter" + } + ] + } + ], + "display_price": 408, + "price": 408 + }, + { + "type": "ORDER_LINE", + "id": "767", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1010532", + "name": "Picnic magere melk", + "image_ids": [ + "aa8880361f045ffcfb9f787e9b7fc2b49907be46921bf42985506dc03baa6c2c" + ], + "unit_quantity": "1 liter", + "unit_quantity_sub": "€1.05/l", + "price": 105, + "max_count": 18, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "1 liter" + } + ] + } + ], + "display_price": 105, + "price": 105 + }, + { + "type": "ORDER_LINE", + "id": "774_775", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1018253", + "name": "Robijn wascapsules wit", + "image_ids": [ + "c78b809ccbcd65760f8ce897e083587ee7b3f2b9719affd80983fad722b5c2d9" + ], + "unit_quantity": "40 wasbeurten", + "price": 2899, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "40 wasbeurten" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1007025", + "name": "Robijn wascapsules kleur", + "image_ids": [ + "ef9c8a371a639906ef20dfdcdc99296fce4102c47f0018e6329a2e4ae9f846b7" + ], + "unit_quantity": "15 wasbeurten", + "price": 879, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "15 wasbeurten" + } + ] + } + ], + "display_price": 3778, + "price": 3778, + "decorators": [ + { + "type": "PROMO", + "text": "1+1 gratis" + }, + { + "type": "PRICE", + "display_price": 1889 + } + ] + }, + { + "type": "ORDER_LINE", + "id": "776_777_778_779_780", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1012699", + "name": "Chinese wokgroenten", + "image_ids": [ + "b0b547a03d1d6021565618a5d32bd35df34c57b348d73252defb776ab8f8ab76" + ], + "unit_quantity": "600 gram", + "unit_quantity_sub": "€4.92/kg", + "price": 295, + "max_count": 50, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "600 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1003425", + "name": "Picnic boerderij-eitjes", + "image_ids": [ + "8be72b8144bfb7ff637d4703cfcb11e1bee789de79c069d00e879650dbf19840" + ], + "unit_quantity": "6 stuks M/L", + "price": 305, + "max_count": 50, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "6 stuks M/L" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1016692", + "name": "Picnic witte snelkookrijst", + "image_ids": [ + "9c76c0a0143bfef650ab85fff4f0918e0b4e2927d79caa2a2bf394f292a86213" + ], + "unit_quantity": "400 gram", + "unit_quantity_sub": "€3.23/kg", + "price": 129, + "max_count": 99, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "400 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1012503", + "name": "Conimex kruidenmix nasi", + "image_ids": [ + "2eb78de465aa327a9739d9b204affce17fdf6bf7675c4fe9fa2d4ec102791c69" + ], + "unit_quantity": "20 gram", + "unit_quantity_sub": "€42.50/kg", + "price": 85, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "20 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1005028", + "name": "Conimex satésaus mild kant & klaar", + "image_ids": [ + "0273de24577ba25526cdf31c53ef2017c62611b2bb4d82475abb2dcd9b2f5b83" + ], + "unit_quantity": "400 gram", + "unit_quantity_sub": "€5.98/kg", + "price": 239, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "400 gram" + } + ] + } + ], + "display_price": 1053, + "price": 1053, + "decorators": [ + { + "type": "PROMO", + "text": "Receptkorting" + }, + { + "type": "PRICE", + "display_price": 880 + } + ] + } + ], + "delivery_slots": [ + { + "slot_id": "611a3b074872b23576bef456a", + "window_start": "2021-03-03T14:45:00.000+01:00", + "window_end": "2021-03-03T15:45:00.000+01:00", + "cut_off_time": "2021-03-02T22:00:00.000+01:00", + "minimum_order_value": 3500 + } + ], + "selected_slot": { + "slot_id": "611a3b074872b23576bef456a", + "state": "EXPLICIT" + }, + "total_count": 10, + "total_price": 2535 +} diff --git a/tests/components/picnic/fixtures/delivery.json b/tests/components/picnic/fixtures/delivery.json new file mode 100644 index 00000000000..61a7fe7ac35 --- /dev/null +++ b/tests/components/picnic/fixtures/delivery.json @@ -0,0 +1,31 @@ +{ + "delivery_id": "z28fjso23e", + "creation_time": "2021-02-24T21:48:46.395+01:00", + "slot": { + "slot_id": "602473859a40dc24c6b65879", + "hub_id": "AMS", + "window_start": "2021-02-26T20:15:00.000+01:00", + "window_end": "2021-02-26T21:15:00.000+01:00", + "cut_off_time": "2021-02-25T22:00:00.000+01:00", + "minimum_order_value": 3500 + }, + "eta2": { + "start": "2021-02-26T20:54:00.000+01:00", + "end": "2021-02-26T21:14:00.000+01:00" + }, + "status": "COMPLETED", + "delivery_time": { + "start": "2021-02-26T20:54:05.221+01:00", + "end": "2021-02-26T20:58:31.802+01:00" + }, + "orders": [ + { + "creation_time": "2021-02-24T21:48:46.418+01:00", + "total_price": 3597 + }, + { + "creation_time": "2021-02-25T17:10:26.816+01:00", + "total_price": 536 + } + ] +} diff --git a/tests/components/picnic/fixtures/user.json b/tests/components/picnic/fixtures/user.json new file mode 100644 index 00000000000..3656d11e98c --- /dev/null +++ b/tests/components/picnic/fixtures/user.json @@ -0,0 +1,14 @@ +{ + "user_id": "295-6y3-1nf4", + "firstname": "User", + "lastname": "Name", + "address": { + "house_number": 123, + "house_number_ext": "a", + "postcode": "4321 AB", + "street": "Commonstreet", + "city": "Somewhere" + }, + "total_deliveries": 123, + "completed_deliveries": 112 +} diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py new file mode 100644 index 00000000000..675651dc588 --- /dev/null +++ b/tests/components/picnic/test_todo.py @@ -0,0 +1,54 @@ +"""Tests for Picnic Tasks todo platform.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_cart_list_with_items(hass: HomeAssistant, init_integration) -> None: + """Test loading of shopping cart.""" + state = hass.states.get("todo.mock_title_shopping_cart") + assert state + assert state.state == "10" + + +async def test_cart_list_empty_items( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without items.""" + mock_picnic_api.get_cart.return_value = {"items": []} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("todo.mock_title_shopping_cart") + assert state + assert state.state == "0" + + +async def test_cart_list_unexpected_response( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without expected response.""" + mock_picnic_api.get_cart.return_value = {} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("todo.mock_title_shopping_cart") + assert state is None + + +async def test_cart_list_null_response( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without response.""" + mock_picnic_api.get_cart.return_value = None + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("todo.mock_title_shopping_cart") + assert state is None From 59469828f1c9e6f31a0236eae76a4f3aa8637619 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Nov 2023 08:43:52 +0100 Subject: [PATCH 651/982] Bump aioesphomeapi to 18.5.6 (#104341) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 910d5dd00bd..89eb6629cf9 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.5.5", + "aioesphomeapi==18.5.6", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 627b4e06eb6..b91b3a3fa06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.5 +aioesphomeapi==18.5.6 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2607d2cad0..a0e5c0b9871 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.5 +aioesphomeapi==18.5.6 # homeassistant.components.flo aioflo==2021.11.0 From 6c6e85f996c58f6eaa993dea39306ac0df1cc867 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 22 Nov 2023 08:50:29 +0100 Subject: [PATCH 652/982] Reolink use parenthesis for multi-line lambda (#104321) Use parenthesis --- homeassistant/components/reolink/binary_sensor.py | 6 ++++-- homeassistant/components/reolink/button.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 5fe14c6223c..e2e8e6b24f9 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -71,8 +71,10 @@ BINARY_SENSORS = ( icon="mdi:dog-side", icon_off="mdi:dog-side-off", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), - supported=lambda api, ch: api.ai_supported(ch, PET_DETECTION_TYPE) - and not api.supported(ch, "ai_animal"), + supported=lambda api, ch: ( + api.ai_supported(ch, PET_DETECTION_TYPE) + and not api.supported(ch, "ai_animal") + ), ), ReolinkBinarySensorEntityDescription( key=PET_DETECTION_TYPE, diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index c98d518be03..8d9f1e55581 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -49,8 +49,9 @@ BUTTON_ENTITIES = ( translation_key="ptz_stop", icon="mdi:pan", enabled_default=lambda api, ch: api.supported(ch, "pan_tilt"), - supported=lambda api, ch: api.supported(ch, "pan_tilt") - or api.supported(ch, "zoom_basic"), + supported=lambda api, ch: ( + api.supported(ch, "pan_tilt") or api.supported(ch, "zoom_basic") + ), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value), ), ReolinkButtonEntityDescription( From cbb5d7ea39eb201f9600394af7f28cc590dad102 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Wed, 22 Nov 2023 03:35:31 -0500 Subject: [PATCH 653/982] Add Linear Garage Door integration (#91436) * Add Linear Garage Door integration * Add Linear Garage Door integration * Remove light platform * Add tests for diagnostics * Changes suggested by Lash * Minor refactoring * Various improvements * Catch up to dev, various fixes * Fix DeviceInfo import * Use the HA dt_util * Update tests/components/linear_garage_door/test_cover.py * Apply suggestions from code review --------- Co-authored-by: Robert Resch Co-authored-by: Erik Montnemery --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/linear_garage_door/__init__.py | 32 +++ .../linear_garage_door/config_flow.py | 166 ++++++++++++++++ .../components/linear_garage_door/const.py | 3 + .../linear_garage_door/coordinator.py | 81 ++++++++ .../components/linear_garage_door/cover.py | 149 ++++++++++++++ .../linear_garage_door/diagnostics.py | 26 +++ .../linear_garage_door/manifest.json | 9 + .../linear_garage_door/strings.json | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/linear_garage_door/__init__.py | 1 + .../linear_garage_door/test_config_flow.py | 161 +++++++++++++++ .../linear_garage_door/test_coordinator.py | 99 ++++++++++ .../linear_garage_door/test_cover.py | 187 ++++++++++++++++++ .../linear_garage_door/test_diagnostics.py | 53 +++++ .../linear_garage_door/test_init.py | 59 ++++++ tests/components/linear_garage_door/util.py | 62 ++++++ 22 files changed, 1134 insertions(+) create mode 100644 homeassistant/components/linear_garage_door/__init__.py create mode 100644 homeassistant/components/linear_garage_door/config_flow.py create mode 100644 homeassistant/components/linear_garage_door/const.py create mode 100644 homeassistant/components/linear_garage_door/coordinator.py create mode 100644 homeassistant/components/linear_garage_door/cover.py create mode 100644 homeassistant/components/linear_garage_door/diagnostics.py create mode 100644 homeassistant/components/linear_garage_door/manifest.json create mode 100644 homeassistant/components/linear_garage_door/strings.json create mode 100644 tests/components/linear_garage_door/__init__.py create mode 100644 tests/components/linear_garage_door/test_config_flow.py create mode 100644 tests/components/linear_garage_door/test_coordinator.py create mode 100644 tests/components/linear_garage_door/test_cover.py create mode 100644 tests/components/linear_garage_door/test_diagnostics.py create mode 100644 tests/components/linear_garage_door/test_init.py create mode 100644 tests/components/linear_garage_door/util.py diff --git a/.strict-typing b/.strict-typing index b2f27fafbbc..3c18a1988f3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -202,6 +202,7 @@ homeassistant.components.ld2410_ble.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* +homeassistant.components.linear_garage_door.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* diff --git a/CODEOWNERS b/CODEOWNERS index 358f2725144..fe45e9c2fca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -700,6 +700,8 @@ build.json @home-assistant/supervisor /tests/components/life360/ @pnbruckner /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core +/homeassistant/components/linear_garage_door/ @IceBotYT +/tests/components/linear_garage_door/ @IceBotYT /homeassistant/components/linux_battery/ @fabaff /homeassistant/components/litejet/ @joncar /tests/components/litejet/ @joncar diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py new file mode 100644 index 00000000000..d168da511e0 --- /dev/null +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -0,0 +1,32 @@ +"""The Linear Garage Door integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LinearUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.COVER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Linear Garage Door from a config entry.""" + + coordinator = LinearUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/linear_garage_door/config_flow.py b/homeassistant/components/linear_garage_door/config_flow.py new file mode 100644 index 00000000000..6bca49adb4c --- /dev/null +++ b/homeassistant/components/linear_garage_door/config_flow.py @@ -0,0 +1,166 @@ +"""Config flow for Linear Garage Door integration.""" +from __future__ import annotations + +from collections.abc import Collection, Mapping, Sequence +import logging +from typing import Any +import uuid + +from linear_garage_door import Linear +from linear_garage_door.errors import InvalidLoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, +} + + +async def validate_input( + hass: HomeAssistant, + data: dict[str, str], +) -> dict[str, Sequence[Collection[str]]]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + hub = Linear() + + device_id = str(uuid.uuid4()) + try: + await hub.login( + data["email"], + data["password"], + device_id=device_id, + client_session=async_get_clientsession(hass), + ) + + sites = await hub.get_sites() + except InvalidLoginError as err: + raise InvalidAuth from err + finally: + await hub.close() + + info = { + "email": data["email"], + "password": data["password"], + "sites": sites, + "device_id": device_id, + } + + return info + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Linear Garage Door.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Sequence[Collection[str]]] = {} + self._reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = STEP_USER_DATA_SCHEMA + + data_schema = vol.Schema(data_schema) + + if user_input is None: + return self.async_show_form(step_id="user", data_schema=data_schema) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = info + + # Check if we are reauthenticating + if self._reauth_entry is not None: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data=self._reauth_entry.data + | {"email": self.data["email"], "password": self.data["password"]}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return await self.async_step_site() + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_site( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the site step.""" + + if isinstance(self.data["sites"], list): + sites: list[dict[str, str]] = self.data["sites"] + + if not user_input: + return self.async_show_form( + step_id="site", + data_schema=vol.Schema( + { + vol.Required("site"): vol.In( + {site["id"]: site["name"] for site in sites} + ) + } + ), + ) + + site_id = user_input["site"] + + site_name = next(site["name"] for site in sites if site["id"] == site_id) + + await self.async_set_unique_id(site_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=site_name, + data={ + "site_id": site_id, + "email": self.data["email"], + "password": self.data["password"], + "device_id": self.data["device_id"], + }, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Reauth in case of a password change or other error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class InvalidDeviceID(HomeAssistantError): + """Error to indicate there is invalid device ID.""" diff --git a/homeassistant/components/linear_garage_door/const.py b/homeassistant/components/linear_garage_door/const.py new file mode 100644 index 00000000000..7b3625c7c67 --- /dev/null +++ b/homeassistant/components/linear_garage_door/const.py @@ -0,0 +1,3 @@ +"""Constants for the Linear Garage Door integration.""" + +DOMAIN = "linear_garage_door" diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py new file mode 100644 index 00000000000..5a17d5a39e4 --- /dev/null +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -0,0 +1,81 @@ +"""DataUpdateCoordinator for Linear.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from linear_garage_door import Linear +from linear_garage_door.errors import InvalidLoginError, ResponseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """DataUpdateCoordinator for Linear.""" + + _email: str + _password: str + _device_id: str + _site_id: str + _devices: list[dict[str, list[str] | str]] | None + _linear: Linear + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize DataUpdateCoordinator for Linear.""" + self._email = entry.data["email"] + self._password = entry.data["password"] + self._device_id = entry.data["device_id"] + self._site_id = entry.data["site_id"] + self._devices = None + + super().__init__( + hass, + _LOGGER, + name="Linear Garage Door", + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Get the data for Linear.""" + + linear = Linear() + + try: + await linear.login( + email=self._email, + password=self._password, + device_id=self._device_id, + ) + except InvalidLoginError as err: + if ( + str(err) + == "Login error: Login provided is invalid, please check the email and password" + ): + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ResponseError as err: + raise ConfigEntryNotReady from err + + if not self._devices: + self._devices = await linear.get_devices(self._site_id) + + data = {} + + for device in self._devices: + device_id = str(device["id"]) + state = await linear.get_device_state(device_id) + data[device_id] = {"name": device["name"], "subdevices": state} + + await linear.close() + + return data diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py new file mode 100644 index 00000000000..3474e9d3acb --- /dev/null +++ b/homeassistant/components/linear_garage_door/cover.py @@ -0,0 +1,149 @@ +"""Cover entity for Linear Garage Doors.""" + +from datetime import timedelta +from typing import Any + +from linear_garage_door import Linear + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LinearUpdateCoordinator + +SUPPORTED_SUBDEVICES = ["GDO"] +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(seconds=10) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Linear Garage Door cover.""" + coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + data = coordinator.data + + device_list: list[LinearCoverEntity] = [] + + for device_id in data: + device_list.extend( + LinearCoverEntity( + device_id=device_id, + device_name=data[device_id]["name"], + subdevice=subdev, + config_entry=config_entry, + coordinator=coordinator, + ) + for subdev in data[device_id]["subdevices"] + if subdev in SUPPORTED_SUBDEVICES + ) + async_add_entities(device_list) + + +class LinearCoverEntity(CoordinatorEntity[LinearUpdateCoordinator], CoverEntity): + """Representation of a Linear cover.""" + + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__( + self, + device_id: str, + device_name: str, + subdevice: str, + config_entry: ConfigEntry, + coordinator: LinearUpdateCoordinator, + ) -> None: + """Init with device ID and name.""" + super().__init__(coordinator) + + self._attr_has_entity_name = True + self._attr_name = None + self._device_id = device_id + self._device_name = device_name + self._subdevice = subdevice + self._attr_device_class = CoverDeviceClass.GARAGE + self._attr_unique_id = f"{device_id}-{subdevice}" + self._config_entry = config_entry + + def _get_data(self, data_property: str) -> str: + """Get a property of the subdevice.""" + return str( + self.coordinator.data[self._device_id]["subdevices"][self._subdevice].get( + data_property + ) + ) + + @property + def device_info(self) -> DeviceInfo: + """Return device info of a garage door.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=self._device_name, + manufacturer="Linear", + model="Garage Door Opener", + ) + + @property + def is_closed(self) -> bool: + """Return if cover is closed.""" + return bool(self._get_data("Open_B") == "false") + + @property + def is_opened(self) -> bool: + """Return if cover is open.""" + return bool(self._get_data("Open_B") == "true") + + @property + def is_opening(self) -> bool: + """Return if cover is opening.""" + return bool(self._get_data("Opening_P") == "0") + + @property + def is_closing(self) -> bool: + """Return if cover is closing.""" + return bool(self._get_data("Opening_P") == "100") + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door.""" + if self.is_closed: + return + + linear = Linear() + + await linear.login( + email=self._config_entry.data["email"], + password=self._config_entry.data["password"], + device_id=self._config_entry.data["device_id"], + client_session=async_get_clientsession(self.hass), + ) + + await linear.operate_device(self._device_id, self._subdevice, "Close") + await linear.close() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door.""" + if self.is_opened: + return + + linear = Linear() + + await linear.login( + email=self._config_entry.data["email"], + password=self._config_entry.data["password"], + device_id=self._config_entry.data["device_id"], + client_session=async_get_clientsession(self.hass), + ) + + await linear.operate_device(self._device_id, self._subdevice, "Open") + await linear.close() diff --git a/homeassistant/components/linear_garage_door/diagnostics.py b/homeassistant/components/linear_garage_door/diagnostics.py new file mode 100644 index 00000000000..fffcdd7de87 --- /dev/null +++ b/homeassistant/components/linear_garage_door/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Linear Garage Door.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LinearUpdateCoordinator + +TO_REDACT = {CONF_PASSWORD, CONF_EMAIL} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "coordinator_data": coordinator.data, + } diff --git a/homeassistant/components/linear_garage_door/manifest.json b/homeassistant/components/linear_garage_door/manifest.json new file mode 100644 index 00000000000..c7918e21e20 --- /dev/null +++ b/homeassistant/components/linear_garage_door/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "linear_garage_door", + "name": "Linear Garage Door", + "codeowners": ["@IceBotYT"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/linear_garage_door", + "iot_class": "cloud_polling", + "requirements": ["linear-garage-door==0.2.7"] +} diff --git a/homeassistant/components/linear_garage_door/strings.json b/homeassistant/components/linear_garage_door/strings.json new file mode 100644 index 00000000000..93dd17c5bce --- /dev/null +++ b/homeassistant/components/linear_garage_door/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d5a5176a974..3bbed6d145b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -262,6 +262,7 @@ FLOWS = { "lidarr", "life360", "lifx", + "linear_garage_door", "litejet", "litterrobot", "livisi", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ec35b83b630..d3685e45432 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3053,6 +3053,12 @@ "config_flow": false, "iot_class": "assumed_state" }, + "linear_garage_door": { + "name": "Linear Garage Door", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "linksys_smart": { "name": "Linksys Smart Wi-Fi", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 41a02600d94..0ed06edaa1d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1781,6 +1781,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.linear_garage_door.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.litejet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index b91b3a3fa06..61e74f00c0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1177,6 +1177,9 @@ lightwave==0.24 # homeassistant.components.limitlessled limitlessled==1.1.3 +# homeassistant.components.linear_garage_door +linear-garage-door==0.2.7 + # homeassistant.components.linode linode-api==4.1.9b1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0e5c0b9871..560d8a9694b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -919,6 +919,9 @@ libsoundtouch==0.8 # homeassistant.components.life360 life360==6.0.0 +# homeassistant.components.linear_garage_door +linear-garage-door==0.2.7 + # homeassistant.components.logi_circle logi-circle==0.2.3 diff --git a/tests/components/linear_garage_door/__init__.py b/tests/components/linear_garage_door/__init__.py new file mode 100644 index 00000000000..e5abc6c943c --- /dev/null +++ b/tests/components/linear_garage_door/__init__.py @@ -0,0 +1 @@ +"""Tests for the Linear Garage Door integration.""" diff --git a/tests/components/linear_garage_door/test_config_flow.py b/tests/components/linear_garage_door/test_config_flow.py new file mode 100644 index 00000000000..88cfca71f98 --- /dev/null +++ b/tests/components/linear_garage_door/test_config_flow.py @@ -0,0 +1,161 @@ +"""Test the Linear Garage Door config flow.""" + +from unittest.mock import patch + +from linear_garage_door.errors import InvalidLoginError + +from homeassistant import config_entries +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .util import async_init_integration + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.get_sites", + return_value=[{"id": "test-site-id", "name": "test-site-name"}], + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ), patch( + "uuid.uuid4", return_value="test-uuid" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.linear_garage_door.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"site": "test-site-id"} + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "test-site-name" + assert result3["data"] == { + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauthentication.""" + + entry = await async_init_integration(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "title_placeholders": {"name": entry.title}, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.get_sites", + return_value=[{"id": "test-site-id", "name": "test-site-name"}], + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ), patch( + "uuid.uuid4", return_value="test-uuid" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "new-email", + "password": "new-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + entries = hass.config_entries.async_entries() + assert len(entries) == 1 + assert entries[0].data == { + "email": "new-email", + "password": "new-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + + +async def test_form_invalid_login(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + side_effect=InvalidLoginError, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_exception(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/linear_garage_door/test_coordinator.py b/tests/components/linear_garage_door/test_coordinator.py new file mode 100644 index 00000000000..fc3087db354 --- /dev/null +++ b/tests/components/linear_garage_door/test_coordinator.py @@ -0,0 +1,99 @@ +"""Test data update coordinator for Linear Garage Door.""" + +from unittest.mock import patch + +from linear_garage_door.errors import InvalidLoginError, ResponseError + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_invalid_password( + hass: HomeAssistant, +) -> None: + """Test invalid password.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=InvalidLoginError( + "Login provided is invalid, please check the email and password" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert flows + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + + +async def test_response_error(hass: HomeAssistant) -> None: + """Test response error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=ResponseError, + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_RETRY + + +async def test_invalid_login( + hass: HomeAssistant, +) -> None: + """Test invalid login.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=InvalidLoginError("Some other error"), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/linear_garage_door/test_cover.py b/tests/components/linear_garage_door/test_cover.py new file mode 100644 index 00000000000..428411d39e0 --- /dev/null +++ b/tests/components/linear_garage_door/test_cover.py @@ -0,0 +1,187 @@ +"""Test Linear Garage Door cover.""" + +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from .util import async_init_integration + +from tests.common import async_fire_time_changed + + +async def test_data(hass: HomeAssistant) -> None: + """Test that data gets parsed and returned appropriately.""" + + await async_init_integration(hass) + + assert hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + assert hass.states.get("cover.test_garage_1").state == STATE_OPEN + assert hass.states.get("cover.test_garage_2").state == STATE_CLOSED + assert hass.states.get("cover.test_garage_3").state == STATE_OPENING + assert hass.states.get("cover.test_garage_4").state == STATE_CLOSING + + +async def test_open_cover(hass: HomeAssistant) -> None: + """Test that opening the cover works as intended.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device" + ) as operate_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert operate_device.call_count == 0 + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device", + return_value=None, + ) as operate_device, patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert operate_device.call_count == 1 + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_garage_2").state == STATE_OPENING + + +async def test_close_cover(hass: HomeAssistant) -> None: + """Test that closing the cover works as intended.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device" + ) as operate_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert operate_device.call_count == 0 + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device", + return_value=None, + ) as operate_device, patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert operate_device.call_count == 1 + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_garage_1").state == STATE_CLOSING diff --git a/tests/components/linear_garage_door/test_diagnostics.py b/tests/components/linear_garage_door/test_diagnostics.py new file mode 100644 index 00000000000..0650196d619 --- /dev/null +++ b/tests/components/linear_garage_door/test_diagnostics.py @@ -0,0 +1,53 @@ +"""Test diagnostics of Linear Garage Door.""" + +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test config entry diagnostics.""" + entry = await async_init_integration(hass) + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["entry"]["data"] == { + "email": "**REDACTED**", + "password": "**REDACTED**", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + assert result["coordinator_data"] == { + "test1": { + "name": "Test Garage 1", + "subdevices": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }, + "test2": { + "name": "Test Garage 2", + "subdevices": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + }, + "test3": { + "name": "Test Garage 3", + "subdevices": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + }, + "test4": { + "name": "Test Garage 4", + "subdevices": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }, + } diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py new file mode 100644 index 00000000000..e8d76770050 --- /dev/null +++ b/tests/components/linear_garage_door/test_init.py @@ -0,0 +1,59 @@ +"""Test Linear Garage Door init.""" + +from unittest.mock import patch + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test the unload entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", + return_value=[ + {"id": "test", "name": "Test Garage", "subdevices": ["GDO", "Light"]} + ], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", + return_value={ + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "10"}, + }, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN] + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + assert entries[0].state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/linear_garage_door/util.py b/tests/components/linear_garage_door/util.py new file mode 100644 index 00000000000..d8348b9bb64 --- /dev/null +++ b/tests/components/linear_garage_door/util.py @@ -0,0 +1,62 @@ +"""Utilities for Linear Garage Door testing.""" + +from unittest.mock import patch + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def async_init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Initialize mock integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + {"id": "test3", "name": "Test Garage 3", "subdevices": ["GDO", "Light"]}, + {"id": "test4", "name": "Test Garage 4", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry From 4f94649ee26a2a93287232e32b1435703a2eec3f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Nov 2023 10:14:46 +0100 Subject: [PATCH 654/982] Update sentry-sdk to 1.36.0 (#104317) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 3828a868649..081f56fe5f6 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.34.0"] + "requirements": ["sentry-sdk==1.36.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 61e74f00c0d..d663f57dab3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2427,7 +2427,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.34.0 +sentry-sdk==1.36.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 560d8a9694b..79285b40e62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1806,7 +1806,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.34.0 +sentry-sdk==1.36.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 254d43dcf75c20785053481936d282ff6b7c522c Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 22 Nov 2023 10:23:19 +0100 Subject: [PATCH 655/982] Support tilt commands for DynamicVenetianBlind in Overkiz (#104330) --- .../components/overkiz/cover_entities/generic_cover.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover_entities/generic_cover.py index b418bba9e41..f4a8a6a0d45 100644 --- a/homeassistant/components/overkiz/cover_entities/generic_cover.py +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -27,12 +27,18 @@ COMMANDS_OPEN: list[OverkizCommand] = [ OverkizCommand.OPEN, OverkizCommand.UP, ] -COMMANDS_OPEN_TILT: list[OverkizCommand] = [OverkizCommand.OPEN_SLATS] +COMMANDS_OPEN_TILT: list[OverkizCommand] = [ + OverkizCommand.OPEN_SLATS, + OverkizCommand.TILT_DOWN, +] COMMANDS_CLOSE: list[OverkizCommand] = [ OverkizCommand.CLOSE, OverkizCommand.DOWN, ] -COMMANDS_CLOSE_TILT: list[OverkizCommand] = [OverkizCommand.CLOSE_SLATS] +COMMANDS_CLOSE_TILT: list[OverkizCommand] = [ + OverkizCommand.CLOSE_SLATS, + OverkizCommand.TILT_UP, +] COMMANDS_SET_TILT_POSITION: list[OverkizCommand] = [OverkizCommand.SET_ORIENTATION] From 02e09ed4cc4727f975d2b1c04ac8940ac2cd7777 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 22 Nov 2023 10:24:10 +0100 Subject: [PATCH 656/982] Update odp-amsterdam lib to v6.0.0 (#104339) --- homeassistant/components/garages_amsterdam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 3f4ffc7fae1..3ce96152337 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==5.3.1"] + "requirements": ["odp-amsterdam==6.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d663f57dab3..318cd337874 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1360,7 +1360,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.3.1 +odp-amsterdam==6.0.0 # homeassistant.components.oem oemthermostat==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79285b40e62..9584328db18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1060,7 +1060,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.3.1 +odp-amsterdam==6.0.0 # homeassistant.components.omnilogic omnilogic==0.4.5 From 0996c82c028532c3dc891bb84ee910366395360a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:20:33 +0100 Subject: [PATCH 657/982] Plugwise: limit _attr_max_temp to 35.0 for thermostats that report a max of 100. (#104324) --- homeassistant/components/plugwise/climate.py | 2 +- tests/components/plugwise/test_climate.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index a33cef0e3a7..42004ce7088 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -67,7 +67,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_preset_modes = presets self._attr_min_temp = self.device["thermostat"]["lower_bound"] - self._attr_max_temp = self.device["thermostat"]["upper_bound"] + self._attr_max_temp = min(self.device["thermostat"]["upper_bound"], 35.0) # Ensure we don't drop below 0.1 self._attr_target_temperature_step = max( self.device["thermostat"]["resolution"], 0.1 diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index d8ce2785f2a..2d9885637df 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -33,7 +33,7 @@ async def test_adam_climate_entity_attributes( assert state.attributes["supported_features"] == 17 assert state.attributes["temperature"] == 21.5 assert state.attributes["min_temp"] == 0.0 - assert state.attributes["max_temp"] == 99.9 + assert state.attributes["max_temp"] == 35.0 assert state.attributes["target_temp_step"] == 0.1 state = hass.states.get("climate.zone_thermostat_jessie") @@ -50,7 +50,7 @@ async def test_adam_climate_entity_attributes( assert state.attributes["preset_mode"] == "asleep" assert state.attributes["temperature"] == 15.0 assert state.attributes["min_temp"] == 0.0 - assert state.attributes["max_temp"] == 99.9 + assert state.attributes["max_temp"] == 35.0 assert state.attributes["target_temp_step"] == 0.1 From 60dcd24bf9961a4648c7b870ba1d24a75e53cdf3 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Wed, 22 Nov 2023 13:32:36 +0100 Subject: [PATCH 658/982] Remove MTrab from Repetier CodeOwners (#104356) Co-authored-by: Franck Nijhof --- CODEOWNERS | 2 +- homeassistant/components/repetier/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index fe45e9c2fca..342f0d35a9b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1055,7 +1055,7 @@ build.json @home-assistant/supervisor /tests/components/reolink/ @starkillerOG /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core -/homeassistant/components/repetier/ @MTrab @ShadowBr0ther +/homeassistant/components/repetier/ @ShadowBr0ther /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 diff --git a/homeassistant/components/repetier/manifest.json b/homeassistant/components/repetier/manifest.json index 5ad3db89ba0..dfddb298284 100644 --- a/homeassistant/components/repetier/manifest.json +++ b/homeassistant/components/repetier/manifest.json @@ -1,7 +1,7 @@ { "domain": "repetier", "name": "Repetier-Server", - "codeowners": ["@MTrab", "@ShadowBr0ther"], + "codeowners": ["@ShadowBr0ther"], "documentation": "https://www.home-assistant.io/integrations/repetier", "iot_class": "local_polling", "loggers": ["pyrepetierng"], From 1f3f073df9ca280cd8f29acd21fd5c4ce67e5924 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 22 Nov 2023 14:08:22 +0100 Subject: [PATCH 659/982] Fix idasen_desk coordinator typing (#104361) --- homeassistant/components/idasen_desk/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py index cb9668dc8f7..b67dec0f579 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -67,7 +67,7 @@ async def async_setup_entry( ) -class IdasenDeskSensor(CoordinatorEntity, SensorEntity): +class IdasenDeskSensor(CoordinatorEntity[IdasenDeskCoordinator], SensorEntity): """IdasenDesk sensor.""" entity_description: IdasenDeskSensorDescription From 01c49ba0e4250d9e50c73a74714308916bc0badb Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 22 Nov 2023 15:24:49 +0100 Subject: [PATCH 660/982] Add recording status for Philips TV (#94691) --- .../components/philips_js/__init__.py | 1 + .../components/philips_js/binary_sensor.py | 105 +++++++++++++++ .../components/philips_js/strings.json | 8 ++ tests/components/philips_js/__init__.py | 126 ++++++++++++++++++ .../philips_js/test_binary_sensor.py | 60 +++++++++ 5 files changed, 300 insertions(+) create mode 100644 homeassistant/components/philips_js/binary_sensor.py create mode 100644 tests/components/philips_js/test_binary_sensor.py diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 969c6c7b837..b81fec90a59 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -36,6 +36,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.REMOTE, Platform.SWITCH, + Platform.BINARY_SENSOR, ] LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py new file mode 100644 index 00000000000..78aa9f17b05 --- /dev/null +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -0,0 +1,105 @@ +"""Philips TV binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass + +from haphilipsjs import PhilipsTV + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PhilipsTVDataUpdateCoordinator +from .const import DOMAIN +from .entity import PhilipsJsEntity + + +@dataclass +class PhilipsTVBinarySensorEntityDescription(BinarySensorEntityDescription): + """A entity description for Philips TV binary sensor.""" + + def __init__(self, recording_value, *args, **kwargs) -> None: + """Set up a binary sensor entity description and add additional attributes.""" + super().__init__(*args, **kwargs) + self.recording_value: str = recording_value + + +DESCRIPTIONS = ( + PhilipsTVBinarySensorEntityDescription( + key="recording_ongoing", + translation_key="recording_ongoing", + icon="mdi:record-rec", + recording_value="RECORDING_ONGOING", + ), + PhilipsTVBinarySensorEntityDescription( + key="recording_new", + translation_key="recording_new", + icon="mdi:new-box", + recording_value="RECORDING_NEW", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the configuration entry.""" + coordinator: PhilipsTVDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + if ( + coordinator.api.json_feature_supported("recordings", "List") + and coordinator.api.api_version == 6 + ): + async_add_entities( + PhilipsTVBinarySensorEntityRecordingType(coordinator, description) + for description in DESCRIPTIONS + ) + + +def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: + """Return True if at least one specified value is available within entry of list.""" + for rec in api.recordings_list["recordings"]: + if rec.get(entry) == value: + return True + return False + + +class PhilipsTVBinarySensorEntityRecordingType(PhilipsJsEntity, BinarySensorEntity): + """A Philips TV binary sensor class, which allows multiple entities given by a BinarySensorEntityDescription.""" + + entity_description: PhilipsTVBinarySensorEntityDescription + + def __init__( + self, + coordinator: PhilipsTVDataUpdateCoordinator, + description: PhilipsTVBinarySensorEntityDescription, + ) -> None: + """Initialize entity class.""" + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self._attr_device_info = coordinator.device_info + self._attr_is_on = _check_for_recording_entry( + coordinator.api, + "RecordingType", + description.recording_value, + ) + + super().__init__(coordinator) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator and set is_on true if one specified value is available within given entry of list.""" + self._attr_is_on = _check_for_recording_entry( + self.coordinator.api, + "RecordingType", + self.entity_description.recording_value, + ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index 6c738a36df3..3ea632ce436 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -44,6 +44,14 @@ } }, "entity": { + "binary_sensor": { + "recording_new": { + "name": "New recording available" + }, + "recording_ongoing": { + "name": "Recording ongoing" + } + }, "light": { "ambilight": { "name": "Ambilight" diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py index f524a586fc8..60e8b238917 100644 --- a/tests/components/philips_js/__init__.py +++ b/tests/components/philips_js/__init__.py @@ -73,3 +73,129 @@ MOCK_CONFIG_PAIRED = { } MOCK_ENTITY_ID = "media_player.philips_tv" + +MOCK_RECORDINGS_LIST = { + "version": "253.91", + "recordings": [ + { + "RecordingId": 36, + "RecordingType": "RECORDING_ONGOING", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676833531, + "Duration": 569, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": 47369, + "EITVersion": 0, + "RetentionInfo": 0, + "EventInfo": "This is a event info which is not rejected by codespell.", + "EventExtendedInfo": "", + "EventGenre": "8", + "RecName": "Terra X", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 72300, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 344, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 0, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676833531, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + { + "RecordingId": 35, + "RecordingType": "RECORDING_NEW", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676832212, + "Duration": 22, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": 47369, + "EITVersion": 0, + "RetentionInfo": -1, + "EventInfo": "This is another event info which is not rejected by codespell.", + "EventExtendedInfo": "", + "EventGenre": "8", + "RecName": "Terra X", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 70980, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 339, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 0, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676832212, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + { + "RecordingId": 34, + "RecordingType": "RECORDING_PARTIALLY_VIEWED", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676677580, + "Duration": 484, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": -1, + "EITVersion": 0, + "RetentionInfo": -1, + "EventInfo": "\n\nAlpine Ski-WM: Parallel-Event, Übertragung aus Méribel/Frankreich\n\n14:10: Biathlon-WM (AD): 20 km Einzel Männer, Übertragung aus Oberhof\nHD-Produktion", + "EventExtendedInfo": "", + "EventGenre": "4", + "RecName": "ZDF HD 2023-02-18 00:46", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 2760, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 328, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 56, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676677581, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + ], +} diff --git a/tests/components/philips_js/test_binary_sensor.py b/tests/components/philips_js/test_binary_sensor.py new file mode 100644 index 00000000000..d11f3fe22f1 --- /dev/null +++ b/tests/components/philips_js/test_binary_sensor.py @@ -0,0 +1,60 @@ +"""The tests for philips_js binary_sensor.""" +import pytest + +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + +from . import MOCK_NAME, MOCK_RECORDINGS_LIST + +ID_RECORDING_AVAILABLE = ( + "binary_sensor." + MOCK_NAME.replace(" ", "_").lower() + "_new_recording_available" +) +ID_RECORDING_ONGOING = ( + "binary_sensor." + MOCK_NAME.replace(" ", "_").lower() + "_recording_ongoing" +) + + +@pytest.fixture +async def mock_tv_api_invalid(mock_tv): + """Set up a invalid mock_tv with should not create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 1 + mock_tv.recordings_list = None + return mock_tv + + +@pytest.fixture +async def mock_tv_api_valid(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = MOCK_RECORDINGS_LIST + return mock_tv + + +async def test_recordings_list_invalid( + mock_tv_api_invalid, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are not created if mock_tv is invalid.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state is None + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state is None + + +async def test_recordings_list_valid( + mock_tv_api_valid, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state is STATE_ON + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state is STATE_ON From 75f237b587b2912e07af896ea114d59f5d403b55 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 22 Nov 2023 16:53:17 +0100 Subject: [PATCH 661/982] Add local API support to Overkiz integration (Somfy TaHoma Developer Mode) (#71644) * Add initial config flow implementation * Add initial config flow implementation * Add todos * Bugfixes * Add first zeroconf code * Fixes for new firmware * Bugfixes for local integration * Delete local token * Fix diagnostics * Update translations and improve code * Update translations and improve code * Add local integration updates * Add local integration updates * Small tweaks * Add comments * Bugfix * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Bugfixes * Small code improvements * Small code improvements * Change Config Flow (breaking change) * Remove token when integration is unloaded * Remove print * Simplify * Bugfixes * Improve configflow * Clean up unnecessary things * Catch nosuchtoken exception * Add migration for Config Flow * Add version 2 migration * Revert change in Config Flow * Fix api type * Update strings * Improve migrate entry * Implement changes * add more comments * Extend diagnostics * Ruff fixes * Clean up code * Bugfixes * Set gateway id * Start writing tests * Add first local test * Code coverage to 64% * Fixes * Remove local token on remove entry * Add debug logging + change manifest * Add developer mode check * Fix not_such_token issue * Small text changes * Bugfix * Fix tests * Address feedback * DRY * Test coverage to 77% * Coverage to 78% * Remove token removal by UUID * Add better retry methods * Clean up * Remove old data * 87% coverage * 90% code coverage * 100% code coverage * Use patch.multiple * Improve tests * Apply pre-commit after rebase * Fix breaking changes in ZeroconfServiceInfo * Add verify_ssl * Fix test import * Fix tests * Catch SSL verify failed * Revert hub to server rename * Move Config Flow version back to 1 * Add diagnostics tests * Fix tests * Fix strings * Implement feedback * Add debug logging for local connection errors * Simplify Config Flow and fix tests * Simplify Config Flow * Fix verify_ssl * Fix rebase mistake * Address feedback * Apply suggestions from code review * Update tests/components/overkiz/test_config_flow.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/overkiz/__init__.py | 70 +- .../components/overkiz/config_flow.py | 305 +++++- homeassistant/components/overkiz/const.py | 12 +- .../components/overkiz/coordinator.py | 4 +- .../components/overkiz/diagnostics.py | 24 +- .../components/overkiz/manifest.json | 6 +- homeassistant/components/overkiz/strings.json | 26 +- homeassistant/generated/integrations.json | 2 +- homeassistant/generated/zeroconf.py | 6 + tests/components/overkiz/conftest.py | 4 +- .../overkiz/snapshots/test_diagnostics.ambr | 2 + tests/components/overkiz/test_config_flow.py | 898 ++++++++++++++---- tests/components/overkiz/test_init.py | 4 +- 13 files changed, 1130 insertions(+), 233 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 36713d972b1..ebc3f96a7f5 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -9,23 +9,32 @@ from typing import cast from aiohttp import ClientError from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS -from pyoverkiz.enums import OverkizState, UIClass, UIWidget +from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, NotSuchTokenException, TooManyRequestsException, ) -from pyoverkiz.models import Device, Scenario, Setup +from pyoverkiz.models import Device, OverkizServer, Scenario, Setup +from pyoverkiz.utils import generate_local_server from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( + CONF_API_TYPE, CONF_HUB, DOMAIN, LOGGER, @@ -48,15 +57,26 @@ class HomeAssistantOverkizData: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Overkiz from a config entry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - server = SUPPORTED_SERVERS[entry.data[CONF_HUB]] + client: OverkizClient | None = None + api_type = entry.data.get(CONF_API_TYPE, APIType.CLOUD) - # To allow users with multiple accounts/hubs, we create a new session so they have separate cookies - session = async_create_clientsession(hass) - client = OverkizClient( - username=username, password=password, session=session, server=server - ) + # Local API + if api_type == APIType.LOCAL: + client = create_local_client( + hass, + host=entry.data[CONF_HOST], + token=entry.data[CONF_TOKEN], + verify_ssl=entry.data[CONF_VERIFY_SSL], + ) + + # Overkiz Cloud API + else: + client = create_cloud_client( + hass, + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + server=SUPPORTED_SERVERS[entry.data[CONF_HUB]], + ) await _async_migrate_entries(hass, entry) @@ -211,3 +231,31 @@ async def _async_migrate_entries( await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) return True + + +def create_local_client( + hass: HomeAssistant, host: str, token: str, verify_ssl: bool +) -> OverkizClient: + """Create Overkiz local client.""" + session = async_create_clientsession(hass, verify_ssl=verify_ssl) + + return OverkizClient( + username="", + password="", + token=token, + session=session, + server=generate_local_server(host=host), + verify_ssl=verify_ssl, + ) + + +def create_cloud_client( + hass: HomeAssistant, username: str, password: str, server: OverkizServer +) -> OverkizClient: + """Create Overkiz cloud client.""" + # To allow users with multiple accounts/hubs, we create a new session so they have separate cookies + session = async_create_clientsession(hass) + + return OverkizClient( + username=username, password=password, session=session, server=server + ) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index eac749f1bc0..03720dce2a8 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -1,31 +1,46 @@ -"""Config flow for Overkiz (by Somfy) integration.""" +"""Config flow for Overkiz integration.""" from __future__ import annotations from collections.abc import Mapping from typing import Any, cast -from aiohttp import ClientError +from aiohttp import ClientConnectorCertificateError, ClientError from pyoverkiz.client import OverkizClient -from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS +from pyoverkiz.enums import APIType, Server from pyoverkiz.exceptions import ( BadCredentialsException, CozyTouchBadCredentialsException, MaintenanceException, + NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, ) -from pyoverkiz.models import obfuscate_id +from pyoverkiz.models import OverkizServer +from pyoverkiz.obfuscate import obfuscate_id +from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_HUB, DEFAULT_HUB, DOMAIN, LOGGER +from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER + + +class DeveloperModeDisabled(HomeAssistantError): + """Error to indicate Somfy Developer Mode is disabled.""" class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -34,45 +49,112 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _config_entry: ConfigEntry | None - _default_user: None | str - _default_hub: str + _api_type: APIType + _user: None | str + _server: str + _host: str def __init__(self) -> None: """Initialize Overkiz Config Flow.""" super().__init__() self._config_entry = None - self._default_user = None - self._default_hub = DEFAULT_HUB + self._api_type = APIType.CLOUD + self._user = None + self._server = DEFAULT_SERVER + self._host = "gateway-xxxx-xxxx-xxxx.local:8443" - async def async_validate_input(self, user_input: dict[str, Any]) -> None: + async def async_validate_input(self, user_input: dict[str, Any]) -> dict[str, Any]: """Validate user credentials.""" - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - server = SUPPORTED_SERVERS[user_input[CONF_HUB]] - session = async_create_clientsession(self.hass) + user_input[CONF_API_TYPE] = self._api_type - client = OverkizClient( - username=username, password=password, server=server, session=session + client = self._create_cloud_client( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + server=SUPPORTED_SERVERS[user_input[CONF_HUB]], ) - await client.login(register_event_listener=False) - # Set first gateway id as unique id + # For Local API, we create and activate a local token + if self._api_type == APIType.LOCAL: + user_input[CONF_TOKEN] = await self._create_local_api_token( + cloud_client=client, + host=user_input[CONF_HOST], + verify_ssl=user_input[CONF_VERIFY_SSL], + ) + + # Set main gateway id as unique id if gateways := await client.get_gateways(): - gateway_id = gateways[0].id - await self.async_set_unique_id(gateway_id) + for gateway in gateways: + if is_overkiz_gateway(gateway.id): + gateway_id = gateway.id + await self.async_set_unique_id(gateway_id) + + return user_input async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step via config flow.""" - errors = {} + if user_input: + self._server = user_input[CONF_HUB] + + # Some Overkiz hubs do support a local API + # Users can choose between local or cloud API. + if self._server in SERVERS_WITH_LOCAL_API: + return await self.async_step_local_or_cloud() + + return await self.async_step_cloud() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HUB, default=self._server): vol.In( + {key: hub.name for key, hub in SUPPORTED_SERVERS.items()} + ), + } + ), + ) + + async def async_step_local_or_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Users can choose between local API or cloud API via config flow.""" + if user_input: + self._api_type = user_input[CONF_API_TYPE] + + if self._api_type == APIType.LOCAL: + return await self.async_step_local() + + return await self.async_step_cloud() + + return self.async_show_form( + step_id="local_or_cloud", + data_schema=vol.Schema( + { + vol.Required(CONF_API_TYPE): vol.In( + { + APIType.LOCAL: "Local API", + APIType.CLOUD: "Cloud API", + } + ), + } + ), + ) + + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the cloud authentication step via config flow.""" + errors: dict[str, str] = {} description_placeholders = {} if user_input: - self._default_user = user_input[CONF_USERNAME] - self._default_hub = user_input[CONF_HUB] + self._user = user_input[CONF_USERNAME] + + # inherit the server from previous step + user_input[CONF_HUB] = self._server try: await self.async_validate_input(user_input) @@ -81,7 +163,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except BadCredentialsException as exception: # If authentication with CozyTouch auth server is valid, but token is invalid # for Overkiz API server, the hardware is not supported. - if user_input[CONF_HUB] == "atlantic_cozytouch" and not isinstance( + if user_input[CONF_HUB] == Server.ATLANTIC_COZYTOUCH and not isinstance( exception, CozyTouchBadCredentialsException ): description_placeholders["unsupported_device"] = "CozyTouch" @@ -99,9 +181,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # the Overkiz API server. Login will return unknown user. description_placeholders["unsupported_device"] = "Somfy Protect" errors["base"] = "unsupported_hardware" - except Exception as exception: # pylint: disable=broad-except + except Exception: # pylint: disable=broad-except errors["base"] = "unknown" - LOGGER.exception(exception) + LOGGER.exception("Unknown error") else: if self._config_entry: if self._config_entry.unique_id != self.unique_id: @@ -132,14 +214,96 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", + step_id="cloud", data_schema=vol.Schema( { - vol.Required(CONF_USERNAME, default=self._default_user): str, + vol.Required(CONF_USERNAME, default=self._user): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_HUB, default=self._default_hub): vol.In( - {key: hub.name for key, hub in SUPPORTED_SERVERS.items()} - ), + } + ), + description_placeholders=description_placeholders, + errors=errors, + ) + + async def async_step_local( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the local authentication step via config flow.""" + errors = {} + description_placeholders = {} + + if user_input: + self._host = user_input[CONF_HOST] + self._user = user_input[CONF_USERNAME] + + # inherit the server from previous step + user_input[CONF_HUB] = self._server + + try: + user_input = await self.async_validate_input(user_input) + except TooManyRequestsException: + errors["base"] = "too_many_requests" + except BadCredentialsException: + errors["base"] = "invalid_auth" + except ClientConnectorCertificateError as exception: + errors["base"] = "certificate_verify_failed" + LOGGER.debug(exception) + except (TimeoutError, ClientError) as exception: + errors["base"] = "cannot_connect" + LOGGER.debug(exception) + except MaintenanceException: + errors["base"] = "server_in_maintenance" + except TooManyAttemptsBannedException: + errors["base"] = "too_many_attempts" + except NotSuchTokenException: + errors["base"] = "no_such_token" + except DeveloperModeDisabled: + errors["base"] = "developer_mode_disabled" + except UnknownUserException: + # Somfy Protect accounts are not supported since they don't use + # the Overkiz API server. Login will return unknown user. + description_placeholders["unsupported_device"] = "Somfy Protect" + errors["base"] = "unsupported_hardware" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + LOGGER.exception("Unknown error") + else: + if self._config_entry: + if self._config_entry.unique_id != self.unique_id: + return self.async_abort(reason="reauth_wrong_account") + + # Update existing entry during reauth + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + **user_input, + }, + ) + + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._config_entry.entry_id + ) + ) + + return self.async_abort(reason="reauth_successful") + + # Create new entry + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="local", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self._host): str, + vol.Required(CONF_USERNAME, default=self._user): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_VERIFY_SSL, default=True): bool, } ), description_placeholders=description_placeholders, @@ -150,6 +314,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle DHCP discovery.""" hostname = discovery_info.hostname gateway_id = hostname[8:22] + self._host = f"gateway-{gateway_id}.local:8443" LOGGER.debug("DHCP discovery detected gateway %s", obfuscate_id(gateway_id)) return await self._process_discovery(gateway_id) @@ -160,8 +325,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle ZeroConf discovery.""" properties = discovery_info.properties gateway_id = properties["gateway_pin"] + hostname = discovery_info.hostname + + LOGGER.debug( + "ZeroConf discovery detected gateway %s on %s (%s)", + obfuscate_id(gateway_id), + hostname, + discovery_info.type, + ) + + if discovery_info.type == "_kizbox._tcp.local.": + self._host = f"gateway-{gateway_id}.local:8443" + + if discovery_info.type == "_kizboxdev._tcp.local.": + self._host = f"{discovery_info.hostname[:-1]}:{discovery_info.port}" + self._api_type = APIType.LOCAL - LOGGER.debug("ZeroConf discovery detected gateway %s", obfuscate_id(gateway_id)) return await self._process_discovery(gateway_id) async def _process_discovery(self, gateway_id: str) -> FlowResult: @@ -183,7 +362,63 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "gateway_id": self._config_entry.unique_id } - self._default_user = self._config_entry.data[CONF_USERNAME] - self._default_hub = self._config_entry.data[CONF_HUB] + self._user = self._config_entry.data[CONF_USERNAME] + self._server = self._config_entry.data[CONF_HUB] + self._api_type = self._config_entry.data[CONF_API_TYPE] + + if self._config_entry.data[CONF_API_TYPE] == APIType.LOCAL: + self._host = self._config_entry.data[CONF_HOST] return await self.async_step_user(dict(entry_data)) + + def _create_cloud_client( + self, username: str, password: str, server: OverkizServer + ) -> OverkizClient: + session = async_create_clientsession(self.hass) + client = OverkizClient( + username=username, password=password, server=server, session=session + ) + + return client + + async def _create_local_api_token( + self, cloud_client: OverkizClient, host: str, verify_ssl: bool + ) -> str: + """Create local API token.""" + # Create session on Somfy cloud server to generate an access token for local API + gateways = await cloud_client.get_gateways() + + gateway_id = "" + for gateway in gateways: + # Overkiz can return multiple gateways, but we only can generate a token + # for the main gateway. + if is_overkiz_gateway(gateway.id): + gateway_id = gateway.id + + developer_mode = await cloud_client.get_setup_option( + f"developerMode-{gateway_id}" + ) + + if developer_mode is None: + raise DeveloperModeDisabled + + token = await cloud_client.generate_local_token(gateway_id) + await cloud_client.activate_local_token( + gateway_id=gateway_id, token=token, label="Home Assistant/local" + ) + + session = async_create_clientsession(self.hass, verify_ssl=verify_ssl) + + # Local API + local_client = OverkizClient( + username="", + password="", + token=token, + session=session, + server=generate_local_server(host=host), + verify_ssl=verify_ssl, + ) + + await local_client.login() + + return token diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 91346b63ce0..b242f6db8e2 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -5,7 +5,13 @@ from datetime import timedelta import logging from typing import Final -from pyoverkiz.enums import MeasuredValueType, OverkizCommandParam, UIClass, UIWidget +from pyoverkiz.enums import ( + MeasuredValueType, + OverkizCommandParam, + Server, + UIClass, + UIWidget, +) from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, @@ -31,8 +37,10 @@ from homeassistant.const import ( DOMAIN: Final = "overkiz" LOGGER: logging.Logger = logging.getLogger(__package__) +CONF_API_TYPE: Final = "api_type" CONF_HUB: Final = "hub" -DEFAULT_HUB: Final = "somfy_europe" +DEFAULT_SERVER: Final = Server.SOMFY_EUROPE +DEFAULT_HOST: Final = "gateway-xxxx-xxxx-xxxx.local:8443" UPDATE_INTERVAL: Final = timedelta(seconds=30) UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index e5079b3d3b8..4630af8bbf8 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ServerDisconnectedError +from aiohttp import ClientConnectorError, ServerDisconnectedError from pyoverkiz.client import OverkizClient from pyoverkiz.enums import EventName, ExecutionState, Protocol from pyoverkiz.exceptions import ( @@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): raise UpdateFailed("Server is down for maintenance.") from exception except InvalidEventListenerIdException as exception: raise UpdateFailed(exception) from exception - except TimeoutError as exception: + except (TimeoutError, ClientConnectorError) as exception: raise UpdateFailed("Failed to connect.") from exception except (ServerDisconnectedError, NotAuthenticatedException): self.executions = {} diff --git a/homeassistant/components/overkiz/diagnostics.py b/homeassistant/components/overkiz/diagnostics.py index 77ca0227579..cb8cf6eb22f 100644 --- a/homeassistant/components/overkiz/diagnostics.py +++ b/homeassistant/components/overkiz/diagnostics.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from pyoverkiz.enums import APIType from pyoverkiz.obfuscate import obfuscate_id from homeassistant.config_entries import ConfigEntry @@ -10,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from . import HomeAssistantOverkizData -from .const import CONF_HUB, DOMAIN +from .const import CONF_API_TYPE, CONF_HUB, DOMAIN async def async_get_config_entry_diagnostics( @@ -23,11 +24,16 @@ async def async_get_config_entry_diagnostics( data = { "setup": await client.get_diagnostic_data(), "server": entry.data[CONF_HUB], - "execution_history": [ - repr(execution) for execution in await client.get_execution_history() - ], + "api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD), } + # Only Overkiz cloud servers expose an endpoint with execution history + if client.api_type == APIType.CLOUD: + execution_history = [ + repr(execution) for execution in await client.get_execution_history() + ] + data["execution_history"] = execution_history + return data @@ -49,11 +55,15 @@ async def async_get_device_diagnostics( }, "setup": await client.get_diagnostic_data(), "server": entry.data[CONF_HUB], - "execution_history": [ + "api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD), + } + + # Only Overkiz cloud servers expose an endpoint with execution history + if client.api_type == APIType.CLOUD: + data["execution_history"] = [ repr(execution) for execution in await client.get_execution_history() if any(command.device_url == device_url for command in execution.commands) - ], - } + ] return data diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index dd78ec78f00..e5c1665b2e4 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -11,13 +11,17 @@ ], "documentation": "https://www.home-assistant.io/integrations/overkiz", "integration_type": "hub", - "iot_class": "cloud_polling", + "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], "requirements": ["pyoverkiz==1.13.3"], "zeroconf": [ { "type": "_kizbox._tcp.local.", "name": "gateway*" + }, + { + "type": "_kizboxdev._tcp.local.", + "name": "gateway*" } ] } diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 82d29a7534a..2a549f1c24d 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -3,18 +3,40 @@ "flow_title": "Gateway: {gateway_id}", "step": { "user": { - "description": "The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo), Rexel (Energeasy Connect) and Atlantic (Cozytouch). Enter your application credentials and select your hub.", + "description": "Select your server. The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo) and Atlantic (Cozytouch).", + "data": { + "hub": "Server" + } + }, + "local_or_cloud": { + "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices are not supported in local API.", + "data": { + "api_type": "API type" + } + }, + "cloud": { + "description": "Enter your application credentials.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "local": { + "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network. \n\n After activation, enter your application credentials and change the host to include your gateway-pin or enter the IP address of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "hub": "Hub" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "certificate_verify_failed": "Cannot connect to host, certificate verify failed.", + "developer_mode_disabled": "Developer Mode disabled. Activate the Developer Mode of your Somfy TaHoma box first.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_such_token": "Cannot create a token for this gateway. Please confirm if the account is linked to this gateway.", "server_in_maintenance": "Server is down for maintenance", "too_many_attempts": "Too many attempts with an invalid token, temporarily banned", "too_many_requests": "Too many requests, try again later", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d3685e45432..50fb66c5f59 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4156,7 +4156,7 @@ "name": "Overkiz", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "local_polling" }, "ovo_energy": { "name": "OVO Energy", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index d97bef19eb4..3c828a54faf 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -508,6 +508,12 @@ ZEROCONF = { "name": "gateway*", }, ], + "_kizboxdev._tcp.local.": [ + { + "domain": "overkiz", + "name": "gateway*", + }, + ], "_lookin._tcp.local.": [ { "domain": "lookin", diff --git a/tests/components/overkiz/conftest.py b/tests/components/overkiz/conftest.py index 990b88d84ed..da6d3a60839 100644 --- a/tests/components/overkiz/conftest.py +++ b/tests/components/overkiz/conftest.py @@ -12,8 +12,8 @@ from tests.components.overkiz import load_setup_fixture from tests.components.overkiz.test_config_flow import ( TEST_EMAIL, TEST_GATEWAY_ID, - TEST_HUB, TEST_PASSWORD, + TEST_SERVER, ) MOCK_SETUP_RESPONSE = Mock(devices=[], gateways=[]) @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: title="Somfy TaHoma Switch", domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) diff --git a/tests/components/overkiz/snapshots/test_diagnostics.ambr b/tests/components/overkiz/snapshots/test_diagnostics.ambr index 06a456f88af..a4ba28ec935 100644 --- a/tests/components/overkiz/snapshots/test_diagnostics.ambr +++ b/tests/components/overkiz/snapshots/test_diagnostics.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_device_diagnostics dict({ + 'api_type': 'cloud', 'device': dict({ 'controllable_name': 'rts:RollerShutterRTSComponent', 'device_url': 'rts://****-****-6867/16756006', @@ -969,6 +970,7 @@ # --- # name: test_diagnostics dict({ + 'api_type': 'cloud', 'execution_history': list([ ]), 'server': 'somfy_europe', diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index a9d950a3a66..146d54feb9c 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1,13 +1,14 @@ -"""Tests for Overkiz (by Somfy) config flow.""" +"""Tests for Overkiz config flow.""" from __future__ import annotations from ipaddress import ip_address from unittest.mock import AsyncMock, Mock, patch -from aiohttp import ClientError +from aiohttp import ClientConnectorCertificateError, ClientError from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, @@ -28,14 +29,18 @@ TEST_EMAIL = "test@testdomain.com" TEST_EMAIL2 = "test@testdomain.nl" TEST_PASSWORD = "test-password" TEST_PASSWORD2 = "test-password2" -TEST_HUB = "somfy_europe" -TEST_HUB2 = "hi_kumo_europe" -TEST_HUB_COZYTOUCH = "atlantic_cozytouch" +TEST_SERVER = "somfy_europe" +TEST_SERVER2 = "hi_kumo_europe" +TEST_SERVER_COZYTOUCH = "atlantic_cozytouch" TEST_GATEWAY_ID = "1234-5678-9123" TEST_GATEWAY_ID2 = "4321-5678-9123" +TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN" + +TEST_HOST = "gateway-1234-5678-9123.local:8443" +TEST_HOST2 = "192.168.11.104:8443" MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] -MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID2)] +MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)] FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.51"), @@ -51,31 +56,133 @@ FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( }, ) +FAKE_ZERO_CONF_INFO_LOCAL = ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.51"), + ip_addresses=[ip_address("192.168.0.51")], + port=8443, + hostname=f"gateway-{TEST_GATEWAY_ID}.local.", + type="_kizboxdev._tcp.local.", + name=f"gateway-{TEST_GATEWAY_ID}._kizboxdev._tcp.local.", + properties={ + "api_version": "1", + "gateway_pin": TEST_GATEWAY_ID, + "fw_version": "2021.5.4-29", + }, +) -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + +async def test_form_cloud(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" - assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, ): - result2 = await hass.config_entries.flow.async_configure( + await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB, - } + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_only_cloud_supported( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER2}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_local_happy_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "host": "gateway-1234-5678-1234.local:8443", + }, + ) await hass.async_block_till_done() @@ -95,23 +202,149 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: (Exception, "unknown"), ], ) -async def test_form_invalid_auth( +async def test_form_invalid_auth_cloud( hass: HomeAssistant, side_effect: Exception, error: str ) -> None: - """Test we handle invalid auth.""" + """Test we handle invalid auth (cloud).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result["step_id"] == config_entries.SOURCE_USER - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": error} + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": error} + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (BadCredentialsException, "invalid_auth"), + (TooManyRequestsException, "too_many_requests"), + ( + ClientConnectorCertificateError(Mock(host=TEST_HOST), Exception), + "certificate_verify_failed", + ), + (TimeoutError, "cannot_connect"), + (ClientError, "cannot_connect"), + (MaintenanceException, "server_in_maintenance"), + (TooManyAttemptsBannedException, "too_many_attempts"), + (UnknownUserException, "unsupported_hardware"), + (NotSuchTokenException, "no_such_token"), + (Exception, "unknown"), + ], +) +async def test_form_invalid_auth_local( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle invalid auth (local).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "verify_ssl": True, + }, + ) + + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": error} + + +async def test_form_local_developer_mode_disabled( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=None), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "host": "gateway-1234-5678-1234.local:8443", + "verify_ssl": True, + }, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": "developer_mode_disabled"} @pytest.mark.parametrize( @@ -123,79 +356,398 @@ async def test_form_invalid_auth( async def test_form_invalid_cozytouch_auth( hass: HomeAssistant, side_effect: Exception, error: str ) -> None: - """Test we handle invalid auth from CozyTouch.""" + """Test we handle invalid auth (cloud).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER_COZYTOUCH}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB_COZYTOUCH, - }, - ) - - assert result["step_id"] == config_entries.SOURCE_USER - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": error} - - -async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: - """Test config flow aborts Config Flow on duplicate entries.""" - MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY_RESPONSE, - ): - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "already_configured" + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.FlowResultType.FORM + assert result3["errors"] == {"base": error} + assert result3["step_id"] == "cloud" -async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: - """Test config flow allows Config Flow unique entries.""" +async def test_cloud_abort_on_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + MockConfigEntry( domain=DOMAIN, - unique_id=TEST_GATEWAY_ID2, - data={"username": "test2@testdomain.com", "password": TEST_PASSWORD}, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.ABORT + assert result4["reason"] == "already_configured" + + +async def test_local_abort_on_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={ + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "verify_ssl": True, + }, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.ABORT + assert result4["reason"] == "already_configured" + + +async def test_cloud_allow_multiple_unique_entries( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + MockConfigEntry( + version=1, + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID2, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { + "api_type": "cloud", + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + } + + +async def test_cloud_reauth_success(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER2, + "api_type": "cloud", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB, - } + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER2, + "api_type": "cloud", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY2_RESPONSE, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_wrong_account" + + +async def test_local_reauth_success(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": TEST_HOST, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID2, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": TEST_HOST, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_wrong_account" async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @@ -213,20 +765,37 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None ): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + }, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": TEST_HUB, + "hub": TEST_SERVER, + "api_type": "cloud", } assert len(mock_setup_entry.mock_calls) == 1 @@ -237,7 +806,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) config_entry.add_to_hass(hass) @@ -266,20 +835,95 @@ async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) - assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, ): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": TEST_HUB, + "hub": TEST_SERVER, + "api_type": "cloud", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_local_zeroconf_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test that zeroconf discovery for new local bridge works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=FAKE_ZERO_CONF_INFO_LOCAL, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False}, + ) + + assert result4["type"] == "create_entry" + assert result4["title"] == "gateway-1234-5678-9123.local:8443" + assert result4["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": "gateway-1234-5678-9123.local:8443", + "api_type": "local", + "token": "1234123412341234", + "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 @@ -290,7 +934,7 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) config_entry.add_to_hass(hass) @@ -302,85 +946,3 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_reauth_success(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, - }, - data=mock_entry.data, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY_RESPONSE, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, - "hub": TEST_HUB2, - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_entry.data["username"] == TEST_EMAIL - assert mock_entry.data["password"] == TEST_PASSWORD2 - - -async def test_reauth_wrong_account(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, - }, - data=mock_entry.data, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY2_RESPONSE, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, - "hub": TEST_HUB2, - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_wrong_account" diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py index 774f3c9a79a..ddecee7c167 100644 --- a/tests/components/overkiz/test_init.py +++ b/tests/components/overkiz/test_init.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_HUB, TEST_PASSWORD +from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_PASSWORD, TEST_SERVER from tests.common import MockConfigEntry, mock_registry @@ -23,7 +23,7 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) mock_entry.add_to_hass(hass) From 5f41d6bbfb0fc754329c65e8d4e8080dcdc68cfb Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 22 Nov 2023 11:34:20 -0500 Subject: [PATCH 662/982] Add better error handling for Roborock initialization (#104181) * Introduce better handling of errors in init for Roborock * patch internally * push exceptions up * remove duplicated test --- homeassistant/components/roborock/__init__.py | 138 ++++++++++++------ tests/components/roborock/test_init.py | 83 +++++++++-- 2 files changed, 159 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index eb9c4a1a066..ff49b352c18 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -2,13 +2,15 @@ from __future__ import annotations import asyncio +from collections.abc import Coroutine from datetime import timedelta import logging +from typing import Any from roborock import RoborockException, RoborockInvalidCredentials from roborock.api import RoborockApiClient from roborock.cloud_api import RoborockMqttClient -from roborock.containers import DeviceData, HomeDataDevice, UserData +from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME @@ -40,61 +42,103 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_map: dict[str, HomeDataDevice] = { device.duid: device for device in home_data.devices + home_data.received_devices } - product_info = {product.id: product for product in home_data.products} - # Create a mqtt_client, which is needed to get the networking information of the device for local connection and in the future, get the map. - mqtt_clients = { - device.duid: RoborockMqttClient( - user_data, DeviceData(device, product_info[device.product_id].model) - ) - for device in device_map.values() + product_info: dict[str, HomeDataProduct] = { + product.id: product for product in home_data.products } - network_results = await asyncio.gather( - *(mqtt_client.get_networking() for mqtt_client in mqtt_clients.values()) - ) - network_info = { - device.duid: result - for device, result in zip(device_map.values(), network_results) - if result is not None - } - if not network_info: - raise ConfigEntryNotReady( - "Could not get network information about your devices" - ) - coordinator_map: dict[str, RoborockDataUpdateCoordinator] = {} - for device_id, device in device_map.items(): - coordinator_map[device_id] = RoborockDataUpdateCoordinator( - hass, - device, - network_info[device_id], - product_info[device.product_id], - mqtt_clients[device.duid], - ) - await asyncio.gather( - *(coordinator.verify_api() for coordinator in coordinator_map.values()) - ) - # If one device update fails - we still want to set up other devices - await asyncio.gather( - *( - coordinator.async_config_entry_first_refresh() - for coordinator in coordinator_map.values() - ), + # Get a Coordinator if the device is available or if we have connected to the device before + coordinators = await asyncio.gather( + *build_setup_functions(hass, device_map, user_data, product_info), return_exceptions=True, ) + # Valid coordinators are those where we had networking cached or we could get networking + valid_coordinators: list[RoborockDataUpdateCoordinator] = [ + coord + for coord in coordinators + if isinstance(coord, RoborockDataUpdateCoordinator) + ] + if len(valid_coordinators) == 0: + raise ConfigEntryNotReady("No coordinators were able to successfully setup.") hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - device_id: coordinator - for device_id, coordinator in coordinator_map.items() - if coordinator.last_update_success - } # Only add coordinators that succeeded - - if not hass.data[DOMAIN][entry.entry_id]: - # Don't start if no coordinators succeeded. - raise ConfigEntryNotReady("There are no devices that can currently be reached.") - + coordinator.roborock_device_info.device.duid: coordinator + for coordinator in valid_coordinators + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +def build_setup_functions( + hass: HomeAssistant, + device_map: dict[str, HomeDataDevice], + user_data: UserData, + product_info: dict[str, HomeDataProduct], +) -> list[Coroutine[Any, Any, RoborockDataUpdateCoordinator | None]]: + """Create a list of setup functions that can later be called asynchronously.""" + setup_functions = [] + for device in device_map.values(): + setup_functions.append( + setup_device(hass, user_data, device, product_info[device.product_id]) + ) + return setup_functions + + +async def setup_device( + hass: HomeAssistant, + user_data: UserData, + device: HomeDataDevice, + product_info: HomeDataProduct, +) -> RoborockDataUpdateCoordinator | None: + """Set up a device Coordinator.""" + mqtt_client = RoborockMqttClient(user_data, DeviceData(device, product_info.name)) + try: + networking = await mqtt_client.get_networking() + if networking is None: + # If the api does not return an error but does return None for + # get_networking - then we need to go through cache checking. + raise RoborockException("Networking request returned None.") + except RoborockException as err: + _LOGGER.warning( + "Not setting up %s because we could not get the network information of the device. " + "Please confirm it is online and the Roborock servers can communicate with it", + device.name, + ) + _LOGGER.debug(err) + raise err + coordinator = RoborockDataUpdateCoordinator( + hass, device, networking, product_info, mqtt_client + ) + # Verify we can communicate locally - if we can't, switch to cloud api + await coordinator.verify_api() + coordinator.api.is_available = True + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + if isinstance(coordinator.api, RoborockMqttClient): + _LOGGER.warning( + "Not setting up %s because the we failed to get data for the first time using the online client. " + "Please ensure your Home Assistant instance can communicate with this device. " + "You may need to open firewall instances on your Home Assistant network and on your Vacuum's network", + device.name, + ) + # Most of the time if we fail to connect using the mqtt client, the problem is due to firewall, + # but in case if it isn't, the error can be included in debug logs for the user to grab. + if coordinator.last_exception: + _LOGGER.debug(coordinator.last_exception) + raise coordinator.last_exception + elif coordinator.last_exception: + # If this is reached, we have verified that we can communicate with the Vacuum locally, + # so if there is an error here - it is not a communication issue but some other problem + extra_error = f"Please create an issue with the following error included: {coordinator.last_exception}" + _LOGGER.warning( + "Not setting up %s because the coordinator failed to get data for the first time using the " + "offline client %s", + device.name, + extra_error, + ) + raise coordinator.last_exception + return coordinator + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index cdeaf03a3da..5d1afaf8f84 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -6,7 +6,6 @@ from roborock import RoborockException, RoborockInvalidCredentials from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -35,8 +34,74 @@ async def test_config_entry_not_ready( with patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data", ), patch( - "homeassistant.components.roborock.RoborockDataUpdateCoordinator._async_update_data", - side_effect=UpdateFailed(), + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_home_data( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> None: + """Test that when we fail to get home data, entry retries.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + side_effect=RoborockException(), + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_get_networking_fails( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture +) -> None: + """Test that when networking fails, we attempt to retry.""" + with patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_get_networking_fails_none( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture +) -> None: + """Test that when networking returns None, we attempt to retry.""" + with patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + return_value=None, + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_cloud_client_fails_props( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture +) -> None: + """Test that if networking succeeds, but we can't communicate with the vacuum, we can't get props, fail.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.ping", + side_effect=RoborockException(), + ), patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClient.get_prop", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_local_client_fails_props( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture +) -> None: + """Test that if networking succeeds, but we can't communicate locally with the vacuum, we can't get props, fail.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + side_effect=RoborockException(), ): await async_setup_component(hass, DOMAIN, {}) assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY @@ -55,15 +120,3 @@ async def test_reauth_started( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" - - -async def test_config_entry_not_ready_home_data( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry -) -> None: - """Test that when we fail to get home data, entry retries.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", - side_effect=RoborockException(), - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY From b5f8b35549bcb324606f0947c72553aeb34c87d9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 22 Nov 2023 17:54:10 +0100 Subject: [PATCH 663/982] Remove Overkiz config flow constructor (#104375) --- .../components/overkiz/config_flow.py | 56 ++++++++----------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 03720dce2a8..8e93b1cc297 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -48,21 +48,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _config_entry: ConfigEntry | None - _api_type: APIType - _user: None | str - _server: str - _host: str - - def __init__(self) -> None: - """Initialize Overkiz Config Flow.""" - super().__init__() - - self._config_entry = None - self._api_type = APIType.CLOUD - self._user = None - self._server = DEFAULT_SERVER - self._host = "gateway-xxxx-xxxx-xxxx.local:8443" + reauth_entry: ConfigEntry | None = None + _api_type: APIType = APIType.CLOUD + _user: str | None = None + _server: str = DEFAULT_SERVER + _host: str = "gateway-xxxx-xxxx-xxxx.local:8443" async def async_validate_input(self, user_input: dict[str, Any]) -> dict[str, Any]: """Validate user credentials.""" @@ -185,22 +175,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" LOGGER.exception("Unknown error") else: - if self._config_entry: - if self._config_entry.unique_id != self.unique_id: + if self.reauth_entry: + if self.reauth_entry.unique_id != self.unique_id: return self.async_abort(reason="reauth_wrong_account") # Update existing entry during reauth self.hass.config_entries.async_update_entry( - self._config_entry, + self.reauth_entry, data={ - **self._config_entry.data, + **self.reauth_entry.data, **user_input, }, ) self.hass.async_create_task( self.hass.config_entries.async_reload( - self._config_entry.entry_id + self.reauth_entry.entry_id ) ) @@ -268,22 +258,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" LOGGER.exception("Unknown error") else: - if self._config_entry: - if self._config_entry.unique_id != self.unique_id: + if self.reauth_entry: + if self.reauth_entry.unique_id != self.unique_id: return self.async_abort(reason="reauth_wrong_account") # Update existing entry during reauth self.hass.config_entries.async_update_entry( - self._config_entry, + self.reauth_entry, data={ - **self._config_entry.data, + **self.reauth_entry.data, **user_input, }, ) self.hass.async_create_task( self.hass.config_entries.async_reload( - self._config_entry.entry_id + self.reauth_entry.entry_id ) ) @@ -353,21 +343,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" - self._config_entry = cast( + self.reauth_entry = cast( ConfigEntry, self.hass.config_entries.async_get_entry(self.context["entry_id"]), ) - self.context["title_placeholders"] = { - "gateway_id": self._config_entry.unique_id - } + self.context["title_placeholders"] = {"gateway_id": self.reauth_entry.unique_id} - self._user = self._config_entry.data[CONF_USERNAME] - self._server = self._config_entry.data[CONF_HUB] - self._api_type = self._config_entry.data[CONF_API_TYPE] + self._user = self.reauth_entry.data[CONF_USERNAME] + self._server = self.reauth_entry.data[CONF_HUB] + self._api_type = self.reauth_entry.data[CONF_API_TYPE] - if self._config_entry.data[CONF_API_TYPE] == APIType.LOCAL: - self._host = self._config_entry.data[CONF_HOST] + if self.reauth_entry.data[CONF_API_TYPE] == APIType.LOCAL: + self._host = self.reauth_entry.data[CONF_HOST] return await self.async_step_user(dict(entry_data)) From 9278db73441d28a7a8a3191fca960c532817d1d2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 22 Nov 2023 18:31:18 +0100 Subject: [PATCH 664/982] Rename variable in Overkiz config flow (#104377) --- .../components/overkiz/config_flow.py | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 8e93b1cc297..4f3f50bf0e8 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -48,7 +48,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - reauth_entry: ConfigEntry | None = None + _reauth_entry: ConfigEntry | None = None _api_type: APIType = APIType.CLOUD _user: str | None = None _server: str = DEFAULT_SERVER @@ -175,22 +175,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" LOGGER.exception("Unknown error") else: - if self.reauth_entry: - if self.reauth_entry.unique_id != self.unique_id: + if self._reauth_entry: + if self._reauth_entry.unique_id != self.unique_id: return self.async_abort(reason="reauth_wrong_account") # Update existing entry during reauth self.hass.config_entries.async_update_entry( - self.reauth_entry, + self._reauth_entry, data={ - **self.reauth_entry.data, + **self._reauth_entry.data, **user_input, }, ) self.hass.async_create_task( self.hass.config_entries.async_reload( - self.reauth_entry.entry_id + self._reauth_entry.entry_id ) ) @@ -258,22 +258,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" LOGGER.exception("Unknown error") else: - if self.reauth_entry: - if self.reauth_entry.unique_id != self.unique_id: + if self._reauth_entry: + if self._reauth_entry.unique_id != self.unique_id: return self.async_abort(reason="reauth_wrong_account") # Update existing entry during reauth self.hass.config_entries.async_update_entry( - self.reauth_entry, + self._reauth_entry, data={ - **self.reauth_entry.data, + **self._reauth_entry.data, **user_input, }, ) self.hass.async_create_task( self.hass.config_entries.async_reload( - self.reauth_entry.entry_id + self._reauth_entry.entry_id ) ) @@ -343,19 +343,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" - self.reauth_entry = cast( + self._reauth_entry = cast( ConfigEntry, self.hass.config_entries.async_get_entry(self.context["entry_id"]), ) - self.context["title_placeholders"] = {"gateway_id": self.reauth_entry.unique_id} + self.context["title_placeholders"] = { + "gateway_id": self._reauth_entry.unique_id + } - self._user = self.reauth_entry.data[CONF_USERNAME] - self._server = self.reauth_entry.data[CONF_HUB] - self._api_type = self.reauth_entry.data[CONF_API_TYPE] + self._user = self._reauth_entry.data[CONF_USERNAME] + self._server = self._reauth_entry.data[CONF_HUB] + self._api_type = self._reauth_entry.data[CONF_API_TYPE] - if self.reauth_entry.data[CONF_API_TYPE] == APIType.LOCAL: - self._host = self.reauth_entry.data[CONF_HOST] + if self._reauth_entry.data[CONF_API_TYPE] == APIType.LOCAL: + self._host = self._reauth_entry.data[CONF_HOST] return await self.async_step_user(dict(entry_data)) From 200804237fed143f23ed19da78daaed687c60033 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 22 Nov 2023 12:56:50 -0500 Subject: [PATCH 665/982] Add binary sensor tests to Skybell (#79568) * Add tests to Skybell * better way to manage cache * uno mas * try ci fix * temporary * undo temporary * ruff * black * uno mas * uno mas * remove problematic test for now * reduce to binary sensor tests * coverage * move cache to json * Update tests/components/skybell/conftest.py --------- Co-authored-by: Erik Montnemery --- .coveragerc | 3 - tests/components/skybell/__init__.py | 11 -- tests/components/skybell/conftest.py | 108 +++++++++++++++++- .../skybell/fixtures/activities.json | 30 +++++ tests/components/skybell/fixtures/avatar.json | 4 + tests/components/skybell/fixtures/cache.json | 40 +++++++ tests/components/skybell/fixtures/device.json | 19 +++ .../skybell/fixtures/device_info.json | 25 ++++ .../skybell/fixtures/device_settings.json | 22 ++++ .../fixtures/device_settings_change.json | 22 ++++ tests/components/skybell/fixtures/login.json | 10 ++ .../skybell/fixtures/login_401.json | 5 + tests/components/skybell/fixtures/me.json | 9 ++ tests/components/skybell/fixtures/video.json | 3 + .../components/skybell/test_binary_sensor.py | 18 +++ tests/components/skybell/test_config_flow.py | 20 ++-- 16 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 tests/components/skybell/fixtures/activities.json create mode 100644 tests/components/skybell/fixtures/avatar.json create mode 100644 tests/components/skybell/fixtures/cache.json create mode 100644 tests/components/skybell/fixtures/device.json create mode 100644 tests/components/skybell/fixtures/device_info.json create mode 100644 tests/components/skybell/fixtures/device_settings.json create mode 100644 tests/components/skybell/fixtures/device_settings_change.json create mode 100644 tests/components/skybell/fixtures/login.json create mode 100644 tests/components/skybell/fixtures/login_401.json create mode 100644 tests/components/skybell/fixtures/me.json create mode 100644 tests/components/skybell/fixtures/video.json create mode 100644 tests/components/skybell/test_binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 8ce4645bf0c..0d7c895f3f3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1124,10 +1124,7 @@ omit = homeassistant/components/sky_hub/* homeassistant/components/skybeacon/sensor.py homeassistant/components/skybell/__init__.py - homeassistant/components/skybell/binary_sensor.py homeassistant/components/skybell/camera.py - homeassistant/components/skybell/coordinator.py - homeassistant/components/skybell/entity.py homeassistant/components/skybell/light.py homeassistant/components/skybell/sensor.py homeassistant/components/skybell/switch.py diff --git a/tests/components/skybell/__init__.py b/tests/components/skybell/__init__.py index fc049adcc3d..ae9b6d132e4 100644 --- a/tests/components/skybell/__init__.py +++ b/tests/components/skybell/__init__.py @@ -1,12 +1 @@ """Tests for the SkyBell integration.""" - -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD - -USERNAME = "user" -PASSWORD = "password" -USER_ID = "123456789012345678901234" - -CONF_CONFIG_FLOW = { - CONF_EMAIL: USERNAME, - CONF_PASSWORD: PASSWORD, -} diff --git a/tests/components/skybell/conftest.py b/tests/components/skybell/conftest.py index 4318fa8c24f..beb3fec9b98 100644 --- a/tests/components/skybell/conftest.py +++ b/tests/components/skybell/conftest.py @@ -1,11 +1,28 @@ -"""Test setup for the SkyBell integration.""" - +"""Configure pytest for Skybell tests.""" from unittest.mock import AsyncMock, patch from aioskybell import Skybell, SkybellDevice +from aioskybell.helpers.const import BASE_URL, USERS_ME_URL +import orjson import pytest -from . import USER_ID +from homeassistant.components.skybell.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +USERNAME = "user" +PASSWORD = "password" +USER_ID = "1234567890abcdef12345678" +DEVICE_ID = "012345670123456789abcdef" + +CONF_DATA = { + CONF_EMAIL: USERNAME, + CONF_PASSWORD: PASSWORD, +} @pytest.fixture(autouse=True) @@ -23,3 +40,88 @@ def skybell_mock(): return_value=mocked_skybell, ), patch("homeassistant.components.skybell.Skybell", return_value=mocked_skybell): yield mocked_skybell + + +def create_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create fixture for adding config entry in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_DATA) + entry.add_to_hass(hass) + return entry + + +async def set_aioclient_responses(aioclient_mock: AiohttpClientMocker) -> None: + """Set AioClient responses.""" + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/info/", + text=load_fixture("skybell/device_info.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/settings/", + text=load_fixture("skybell/device_settings.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/activities/", + text=load_fixture("skybell/activities.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/", + text=load_fixture("skybell/device.json"), + ) + aioclient_mock.get( + USERS_ME_URL, + text=load_fixture("skybell/me.json"), + ) + aioclient_mock.post( + f"{BASE_URL}login/", + text=load_fixture("skybell/login.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/activities/1234567890ab1234567890ac/video/", + text=load_fixture("skybell/video.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/avatar/", + text=load_fixture("skybell/avatar.json"), + ) + aioclient_mock.get( + f"https://v3-production-devices-avatar.s3.us-west-2.amazonaws.com/{DEVICE_ID}.jpg", + ) + aioclient_mock.get( + f"https://skybell-thumbnails-stage.s3.amazonaws.com/{DEVICE_ID}/1646859244793-951{DEVICE_ID}_{DEVICE_ID}.jpeg", + ) + + +@pytest.fixture +async def connection(aioclient_mock: AiohttpClientMocker) -> None: + """Fixture for good connection responses.""" + await set_aioclient_responses(aioclient_mock) + + +def create_skybell(hass: HomeAssistant) -> Skybell: + """Create Skybell object.""" + skybell = Skybell( + username=USERNAME, + password=PASSWORD, + get_devices=True, + session=async_get_clientsession(hass), + ) + skybell._cache = orjson.loads(load_fixture("skybell/cache.json")) + return skybell + + +def mock_skybell(hass: HomeAssistant): + """Mock Skybell object.""" + return patch( + "homeassistant.components.skybell.Skybell", return_value=create_skybell(hass) + ) + + +async def async_init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Skybell integration in Home Assistant.""" + config_entry = create_entry(hass) + + with mock_skybell(hass), patch("aioskybell.utils.async_save_cache"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/skybell/fixtures/activities.json b/tests/components/skybell/fixtures/activities.json new file mode 100644 index 00000000000..4ed5c027821 --- /dev/null +++ b/tests/components/skybell/fixtures/activities.json @@ -0,0 +1,30 @@ +[ + { + "videoState": "download:ready", + "_id": "1234567890ab1234567890ab", + "device": "0123456789abcdef01234567", + "callId": "1234567890123-1234567890abcd1234567890abcd", + "event": "device:sensor:motion", + "state": "ready", + "ttlStartDate": "2020-03-30T12:35:02.204Z", + "createdAt": "2020-03-30T12:35:02.204Z", + "updatedAt": "2020-03-30T12:35:02.566Z", + "id": "1234567890ab1234567890ab", + "media": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcdef.jpeg", + "mediaSmall": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcdef_small.jpeg" + }, + { + "videoState": "download:ready", + "_id": "1234567890ab1234567890a9", + "device": "0123456789abcdef01234567", + "callId": "1234567890123-1234567890abcd1234567890abc9", + "event": "application:on-demand", + "state": "ready", + "ttlStartDate": "2020-03-30T11:35:02.204Z", + "createdAt": "2020-03-30T11:35:02.204Z", + "updatedAt": "2020-03-30T11:35:02.566Z", + "id": "1234567890ab1234567890a9", + "media": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcde9.jpeg", + "mediaSmall": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcde9_small.jpeg" + } +] diff --git a/tests/components/skybell/fixtures/avatar.json b/tests/components/skybell/fixtures/avatar.json new file mode 100644 index 00000000000..3f8157c15c8 --- /dev/null +++ b/tests/components/skybell/fixtures/avatar.json @@ -0,0 +1,4 @@ +{ + "createdAt": "2020-03-31T04:13:48.640Z", + "url": "https://v3-production-devices-avatar.s3.us-west-2.amazonaws.com/012345670123456789abcdef.jpg" +} diff --git a/tests/components/skybell/fixtures/cache.json b/tests/components/skybell/fixtures/cache.json new file mode 100644 index 00000000000..1276c2cfc0f --- /dev/null +++ b/tests/components/skybell/fixtures/cache.json @@ -0,0 +1,40 @@ +{ + "app_id": "secret", + "client_id": "secret", + "token": "secret", + "access_token": "secret", + "devices": { + "5f8ef594362f31000833d959": { + "event": { + "device:sensor:motion": { + "videoState": "download:ready", + "_id": "1234567890ab1234567890ab", + "device": "0123456789abcdef01234567", + "callId": "1234567890123-1234567890abcd1234567890abcd", + "event": "device:sensor:motion", + "state": "ready", + "ttlStartDate": "2020-03-30T12:35:02.204Z", + "createdAt": "2020-03-30T12:35:02.204Z", + "updatedAt": "2020-03-30T12:35:02.566Z", + "id": "1234567890ab1234567890ab", + "media": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcdef.jpeg", + "mediaSmall": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcdef_small.jpeg" + }, + "device:sensor:button": { + "videoState": "download:ready", + "_id": "1234567890ab1234567890a9", + "device": "0123456789abcdef01234567", + "callId": "1234567890123-1234567890abcd1234567890abc9", + "event": "application:on-demand", + "state": "ready", + "ttlStartDate": "2020-03-30T11:35:02.204Z", + "createdAt": "2020-03-30T11:35:02.204Z", + "updatedAt": "2020-03-30T11:35:02.566Z", + "id": "1234567890ab1234567890a9", + "media": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcde9.jpeg", + "mediaSmall": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcde9_small.jpeg" + } + } + } + } +} diff --git a/tests/components/skybell/fixtures/device.json b/tests/components/skybell/fixtures/device.json new file mode 100644 index 00000000000..7b522aa687d --- /dev/null +++ b/tests/components/skybell/fixtures/device.json @@ -0,0 +1,19 @@ +[ + { + "user": "0123456789abcdef01234567", + "uuid": "0123456789", + "resourceId": "012345670123456789abcdef", + "deviceInviteToken": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "location": { + "lat": "-1.0", + "lng": "1.0" + }, + "name": "Front Door", + "type": "skybell hd", + "status": "up", + "createdAt": "2020-10-20T14:35:00.745Z", + "updatedAt": "2020-10-20T14:35:00.745Z", + "id": "012345670123456789abcdef", + "acl": "owner" + } +] diff --git a/tests/components/skybell/fixtures/device_info.json b/tests/components/skybell/fixtures/device_info.json new file mode 100644 index 00000000000..d858bb20e36 --- /dev/null +++ b/tests/components/skybell/fixtures/device_info.json @@ -0,0 +1,25 @@ +{ + "essid": "wifi", + "wifiBitrate": "39", + "proxy_port": "5683", + "wifiLinkQuality": "43", + "port": "5683", + "mac": "ff:ff:ff:ff:ff:ff", + "serialNo": "0123456789", + "wifiTxPwrEeprom": "12", + "region": "us-west-2", + "hardwareRevision": "SKYBELL_TRIMPLUS_1000030-F", + "proxy_address": "34.209.204.201", + "wifiSignalLevel": "-67", + "localHostname": "ip-10-0-0-67.us-west-2.compute.internal", + "wifiNoise": "0", + "address": "1.2.3.4", + "clientId": "1234567890abcdef1234567890abcdef1234567890abcdef", + "timestamp": "60000000000", + "deviceId": "01234567890abcdef1234567", + "firmwareVersion": "7082", + "checkedInAt": "2020-03-31T04:13:37.000Z", + "status": { + "wifiLink": "poor" + } +} diff --git a/tests/components/skybell/fixtures/device_settings.json b/tests/components/skybell/fixtures/device_settings.json new file mode 100644 index 00000000000..46af5f0bd4b --- /dev/null +++ b/tests/components/skybell/fixtures/device_settings.json @@ -0,0 +1,22 @@ +{ + "ring_tone": "0", + "do_not_ring": "false", + "do_not_disturb": "false", + "digital_doorbell": "false", + "video_profile": "1", + "mic_volume": "63", + "speaker_volume": "96", + "chime_level": "1", + "motion_threshold": "32", + "low_lux_threshold": "50", + "med_lux_threshold": "150", + "high_lux_threshold": "400", + "low_front_led_dac": "10", + "med_front_led_dac": "10", + "high_front_led_dac": "10", + "green_r": "0", + "green_g": "0", + "green_b": "255", + "led_intensity": "0", + "motion_policy": "call" +} diff --git a/tests/components/skybell/fixtures/device_settings_change.json b/tests/components/skybell/fixtures/device_settings_change.json new file mode 100644 index 00000000000..6e2c8dd199b --- /dev/null +++ b/tests/components/skybell/fixtures/device_settings_change.json @@ -0,0 +1,22 @@ +{ + "ring_tone": "0", + "do_not_ring": "false", + "do_not_disturb": "false", + "digital_doorbell": "false", + "video_profile": "1", + "mic_volume": "63", + "speaker_volume": "96", + "chime_level": "1", + "motion_threshold": "32", + "low_lux_threshold": "50", + "med_lux_threshold": "150", + "high_lux_threshold": "400", + "low_front_led_dac": "10", + "med_front_led_dac": "10", + "high_front_led_dac": "10", + "green_r": "10", + "green_g": "125", + "green_b": "255", + "led_intensity": "50", + "motion_policy": "disabled" +} diff --git a/tests/components/skybell/fixtures/login.json b/tests/components/skybell/fixtures/login.json new file mode 100644 index 00000000000..c7eaa44b5ab --- /dev/null +++ b/tests/components/skybell/fixtures/login.json @@ -0,0 +1,10 @@ +{ + "firstName": "John", + "lastName": "Doe", + "resourceId": "0123456789abcdef01234567", + "createdAt": "2018-07-06T02:02:14.050Z", + "updatedAt": "2018-07-06T02:02:14.050Z", + "id": "0123456789abcdef01234567", + "userLinks": [], + "access_token": "superlongkey" +} diff --git a/tests/components/skybell/fixtures/login_401.json b/tests/components/skybell/fixtures/login_401.json new file mode 100644 index 00000000000..ab6bfd7053c --- /dev/null +++ b/tests/components/skybell/fixtures/login_401.json @@ -0,0 +1,5 @@ +{ + "errors": { + "message": "Invalid Login - SmartAuth" + } +} diff --git a/tests/components/skybell/fixtures/me.json b/tests/components/skybell/fixtures/me.json new file mode 100644 index 00000000000..7b27c95ec01 --- /dev/null +++ b/tests/components/skybell/fixtures/me.json @@ -0,0 +1,9 @@ +{ + "firstName": "First", + "lastName": "Last", + "resourceId": "123456789012345678901234", + "createdAt": "2018-10-06T02:02:14.050Z", + "updatedAt": "2018-10-06T02:02:14.050Z", + "id": "1234567890abcdef12345678", + "userLinks": [] +} diff --git a/tests/components/skybell/fixtures/video.json b/tests/components/skybell/fixtures/video.json new file mode 100644 index 00000000000..e674df1c9c8 --- /dev/null +++ b/tests/components/skybell/fixtures/video.json @@ -0,0 +1,3 @@ +{ + "url": "https://production-video-download.s3.us-west-2.amazonaws.com/012345670123456789abcdef/1654307756676-0123456789120123456789abcdef_012345670123456789abcdef.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=01234567890123456789%2F20203030%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20200330T201225Z&X-Amz-Expires=300&X-Amz-Signature=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef&X-Amz-SignedHeaders=host" +} diff --git a/tests/components/skybell/test_binary_sensor.py b/tests/components/skybell/test_binary_sensor.py new file mode 100644 index 00000000000..8e0bc884730 --- /dev/null +++ b/tests/components/skybell/test_binary_sensor.py @@ -0,0 +1,18 @@ +"""Binary sensor tests for the Skybell integration.""" +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .conftest import async_init_integration + + +async def test_binary_sensors(hass: HomeAssistant, connection) -> None: + """Test we get sensor data.""" + await async_init_integration(hass) + + state = hass.states.get("binary_sensor.front_door_button") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.OCCUPANCY + state = hass.states.get("binary_sensor.front_door_motion") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.MOTION diff --git a/tests/components/skybell/test_config_flow.py b/tests/components/skybell/test_config_flow.py index f93c1d6ae4f..d83f4243d7f 100644 --- a/tests/components/skybell/test_config_flow.py +++ b/tests/components/skybell/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import CONF_CONFIG_FLOW, PASSWORD, USER_ID +from .conftest import CONF_DATA, PASSWORD, USER_ID from tests.common import MockConfigEntry @@ -37,12 +37,12 @@ async def test_flow_user(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONF_CONFIG_FLOW, + user_input=CONF_DATA, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "user" - assert result["data"] == CONF_CONFIG_FLOW + assert result["data"] == CONF_DATA assert result["result"].unique_id == USER_ID @@ -50,12 +50,12 @@ async def test_flow_user_already_configured(hass: HomeAssistant) -> None: """Test user initialized flow with duplicate server.""" entry = MockConfigEntry( domain=DOMAIN, - data=CONF_CONFIG_FLOW, + data=CONF_DATA, ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] == FlowResultType.ABORT @@ -66,7 +66,7 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant, skybell_mock) -> No """Test user initialized flow with unreachable server.""" skybell_mock.async_initialize.side_effect = exceptions.SkybellException(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" @@ -79,7 +79,7 @@ async def test_invalid_credentials(hass: HomeAssistant, skybell_mock) -> None: exceptions.SkybellAuthenticationException(hass) ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] == FlowResultType.FORM @@ -91,7 +91,7 @@ async def test_flow_user_unknown_error(hass: HomeAssistant, skybell_mock) -> Non """Test user initialized flow with unreachable server.""" skybell_mock.async_initialize.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" @@ -100,7 +100,7 @@ async def test_flow_user_unknown_error(hass: HomeAssistant, skybell_mock) -> Non async def test_step_reauth(hass: HomeAssistant) -> None: """Test the reauth flow.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_CONFIG_FLOW) + entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_DATA) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -126,7 +126,7 @@ async def test_step_reauth(hass: HomeAssistant) -> None: async def test_step_reauth_failed(hass: HomeAssistant, skybell_mock) -> None: """Test the reauth flow fails and recovers.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_CONFIG_FLOW) + entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_DATA) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( From 7a727dc3ad0d87faeabb19029ab5f41fb7fe86c0 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:04:49 +0000 Subject: [PATCH 666/982] Migrate Aurora_ABB_Powerone to DataUpdateCoordinator (#72363) * Refactor to DataUpdateCoordinator * Fix tests for sunset/sunrise * Correct time offsets in tests * Fix time intervals (attempt 2) * Merge dev * Fix tests after rebase * Fix isort * Address review comments: const and increase cov * Fix merge problems * Refactor, removing unnecessary file * Perform blocking serial IO in the executor * Replace deprecated async_setup_platforms * Update based on review comments * Fix tests * Update based on review comments. * Update homeassistant/components/aurora_abb_powerone/sensor.py Co-authored-by: Joost Lekkerkerker * Use freezer for time deltas. * Address review comments --------- Co-authored-by: Dave T Co-authored-by: Joost Lekkerkerker --- .../aurora_abb_powerone/__init__.py | 66 ++++++++++++++- .../aurora_abb_powerone/aurora_device.py | 57 ------------- .../components/aurora_abb_powerone/const.py | 3 + .../components/aurora_abb_powerone/sensor.py | 83 +++++++------------ .../aurora_abb_powerone/test_config_flow.py | 4 - .../aurora_abb_powerone/test_init.py | 3 - .../aurora_abb_powerone/test_sensor.py | 32 +++++-- 7 files changed, 119 insertions(+), 129 deletions(-) delete mode 100644 homeassistant/components/aurora_abb_powerone/aurora_device.py diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index b5dc236dfa2..43e3bd2ad5c 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -12,13 +12,14 @@ import logging -from aurorapy.client import AuroraSerialClient +from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import DOMAIN, SCAN_INTERVAL PLATFORMS = [Platform.SENSOR] @@ -30,8 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: comport = entry.data[CONF_PORT] address = entry.data[CONF_ADDRESS] - ser_client = AuroraSerialClient(address, comport, parity="N", timeout=1) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ser_client + coordinator = AuroraAbbDataUpdateCoordinator(hass, comport, address) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -47,3 +50,58 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): + """Class to manage fetching AuroraAbbPowerone data.""" + + def __init__(self, hass: HomeAssistant, comport: str, address: int) -> None: + """Initialize the data update coordinator.""" + self.available_prev = False + self.available = False + self.client = AuroraSerialClient(address, comport, parity="N", timeout=1) + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + def _update_data(self) -> dict[str, float]: + """Fetch new state data for the sensor. + + This is the only function that should fetch new data for Home Assistant. + """ + data: dict[str, float] = {} + self.available_prev = self.available + try: + self.client.connect() + + # read ADC channel 3 (grid power output) + power_watts = self.client.measure(3, True) + temperature_c = self.client.measure(21) + energy_wh = self.client.cumulated_energy(5) + except AuroraTimeoutError: + self.available = False + _LOGGER.debug("No response from inverter (could be dark)") + except AuroraError as error: + self.available = False + raise error + else: + data["instantaneouspower"] = round(power_watts, 1) + data["temp"] = round(temperature_c, 1) + data["totalenergy"] = round(energy_wh / 1000, 2) + self.available = True + + finally: + if self.available != self.available_prev: + if self.available: + _LOGGER.info("Communication with %s back online", self.name) + else: + _LOGGER.warning( + "Communication with %s lost", + self.name, + ) + if self.client.serline.isOpen(): + self.client.close() + + return data + + async def _async_update_data(self) -> dict[str, float]: + """Update inverter data in the executor.""" + return await self.hass.async_add_executor_job(self._update_data) diff --git a/homeassistant/components/aurora_abb_powerone/aurora_device.py b/homeassistant/components/aurora_abb_powerone/aurora_device.py deleted file mode 100644 index e9ca9e47121..00000000000 --- a/homeassistant/components/aurora_abb_powerone/aurora_device.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Top level class for AuroraABBPowerOneSolarPV inverters and sensors.""" -from __future__ import annotations - -from collections.abc import Mapping -import logging -from typing import Any - -from aurorapy.client import AuroraSerialClient - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity - -from .const import ( - ATTR_DEVICE_NAME, - ATTR_FIRMWARE, - ATTR_MODEL, - ATTR_SERIAL_NUMBER, - DEFAULT_DEVICE_NAME, - DOMAIN, - MANUFACTURER, -) - -_LOGGER = logging.getLogger(__name__) - - -class AuroraEntity(Entity): - """Representation of an Aurora ABB PowerOne device.""" - - def __init__(self, client: AuroraSerialClient, data: Mapping[str, Any]) -> None: - """Initialise the basic device.""" - self._data = data - self.type = "device" - self.client = client - self._available = True - - @property - def unique_id(self) -> str | None: - """Return the unique id for this device.""" - if (serial := self._data.get(ATTR_SERIAL_NUMBER)) is None: - return None - return f"{serial}_{self.entity_description.key}" - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( - identifiers={(DOMAIN, self._data[ATTR_SERIAL_NUMBER])}, - manufacturer=MANUFACTURER, - model=self._data[ATTR_MODEL], - name=self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), - sw_version=self._data[ATTR_FIRMWARE], - ) diff --git a/homeassistant/components/aurora_abb_powerone/const.py b/homeassistant/components/aurora_abb_powerone/const.py index 3711dd6d800..d1266a838c3 100644 --- a/homeassistant/components/aurora_abb_powerone/const.py +++ b/homeassistant/components/aurora_abb_powerone/const.py @@ -1,5 +1,7 @@ """Constants for the Aurora ABB PowerOne integration.""" +from datetime import timedelta + DOMAIN = "aurora_abb_powerone" # Min max addresses and default according to here: @@ -8,6 +10,7 @@ DOMAIN = "aurora_abb_powerone" MIN_ADDRESS = 2 MAX_ADDRESS = 63 DEFAULT_ADDRESS = 2 +SCAN_INTERVAL = timedelta(seconds=30) DEFAULT_INTEGRATION_TITLE = "PhotoVoltaic Inverters" DEFAULT_DEVICE_NAME = "Solar Inverter" diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 55f3be5d6db..0e7d0c06a4e 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,8 +5,6 @@ from collections.abc import Mapping import logging from typing import Any -from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -21,10 +19,21 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .aurora_device import AuroraEntity -from .const import DOMAIN +from . import AuroraAbbDataUpdateCoordinator +from .const import ( + ATTR_DEVICE_NAME, + ATTR_FIRMWARE, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + DEFAULT_DEVICE_NAME, + DOMAIN, + MANUFACTURER, +) _LOGGER = logging.getLogger(__name__) @@ -61,70 +70,40 @@ async def async_setup_entry( """Set up aurora_abb_powerone sensor based on a config entry.""" entities = [] - client = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] data = config_entry.data for sens in SENSOR_TYPES: - entities.append(AuroraSensor(client, data, sens)) + entities.append(AuroraSensor(coordinator, data, sens)) _LOGGER.debug("async_setup_entry adding %d entities", len(entities)) async_add_entities(entities, True) -class AuroraSensor(AuroraEntity, SensorEntity): - """Representation of a Sensor on a Aurora ABB PowerOne Solar inverter.""" +class AuroraSensor(CoordinatorEntity[AuroraAbbDataUpdateCoordinator], SensorEntity): + """Representation of a Sensor on an Aurora ABB PowerOne Solar inverter.""" _attr_has_entity_name = True def __init__( self, - client: AuroraSerialClient, + coordinator: AuroraAbbDataUpdateCoordinator, data: Mapping[str, Any], entity_description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(client, data) + super().__init__(coordinator) self.entity_description = entity_description - self.available_prev = True + self._attr_unique_id = f"{data[ATTR_SERIAL_NUMBER]}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, data[ATTR_SERIAL_NUMBER])}, + manufacturer=MANUFACTURER, + model=data[ATTR_MODEL], + name=data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), + sw_version=data[ATTR_FIRMWARE], + ) - def update(self) -> None: - """Fetch new state data for the sensor. - - This is the only method that should fetch new data for Home Assistant. - """ - try: - self.available_prev = self._attr_available - self.client.connect() - if self.entity_description.key == "instantaneouspower": - # read ADC channel 3 (grid power output) - power_watts = self.client.measure(3, True) - self._attr_native_value = round(power_watts, 1) - elif self.entity_description.key == "temp": - temperature_c = self.client.measure(21) - self._attr_native_value = round(temperature_c, 1) - elif self.entity_description.key == "totalenergy": - energy_wh = self.client.cumulated_energy(5) - self._attr_native_value = round(energy_wh / 1000, 2) - self._attr_available = True - - except AuroraTimeoutError: - self._attr_state = None - self._attr_native_value = None - self._attr_available = False - _LOGGER.debug("No response from inverter (could be dark)") - except AuroraError as error: - self._attr_state = None - self._attr_native_value = None - self._attr_available = False - raise error - finally: - if self._attr_available != self.available_prev: - if self._attr_available: - _LOGGER.info("Communication with %s back online", self.name) - else: - _LOGGER.warning( - "Communication with %s lost", - self.name, - ) - if self.client.serline.isOpen(): - self.client.close() + @property + def native_value(self) -> StateType: + """Get the value of the sensor from previously collected data.""" + return self.coordinator.data.get(self.entity_description.key) diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index b30da3ce348..d156dce2154 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -1,5 +1,4 @@ """Test the Aurora ABB PowerOne Solar PV config flow.""" -from logging import INFO from unittest.mock import patch from aurorapy.client import AuroraError, AuroraTimeoutError @@ -49,9 +48,6 @@ async def test_form(hass: HomeAssistant) -> None: ), patch( "aurorapy.client.AuroraSerialClient.firmware", return_value="1.234", - ), patch( - "homeassistant.components.aurora_abb_powerone.config_flow._LOGGER.getEffectiveLevel", - return_value=INFO, ) as mock_setup, patch( "homeassistant.components.aurora_abb_powerone.async_setup_entry", return_value=True, diff --git a/tests/components/aurora_abb_powerone/test_init.py b/tests/components/aurora_abb_powerone/test_init.py index f88cab0cb46..92b448d8645 100644 --- a/tests/components/aurora_abb_powerone/test_init.py +++ b/tests/components/aurora_abb_powerone/test_init.py @@ -18,9 +18,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: """Test unloading the aurora_abb_powerone entry.""" with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( - "homeassistant.components.aurora_abb_powerone.sensor.AuroraSensor.update", - return_value=None, - ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", ), patch( diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 8fbe29f9979..61521c49b79 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -1,8 +1,8 @@ """Test the Aurora ABB PowerOne Solar PV sensors.""" -from datetime import timedelta from unittest.mock import patch from aurorapy.client import AuroraError, AuroraTimeoutError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.aurora_abb_powerone.const import ( ATTR_DEVICE_NAME, @@ -11,10 +11,10 @@ from homeassistant.components.aurora_abb_powerone.const import ( ATTR_SERIAL_NUMBER, DEFAULT_INTEGRATION_TITLE, DOMAIN, + SCAN_INTERVAL, ) from homeassistant.const import CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -95,14 +95,16 @@ async def test_sensors(hass: HomeAssistant) -> None: assert energy.state == "12.35" -async def test_sensor_dark(hass: HomeAssistant) -> None: +async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test that darkness (no comms) is handled correctly.""" mock_entry = _mock_config_entry() - utcnow = dt_util.utcnow() # sun is up with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=_simulated_returns, ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -128,16 +130,24 @@ async def test_sensor_dark(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraTimeoutError("No response after 10 seconds"), + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=AuroraTimeoutError("No response after 3 tries"), ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) await hass.async_block_till_done() - power = hass.states.get("sensor.mydevicename_power_output") + power = hass.states.get("sensor.mydevicename_total_energy") assert power.state == "unknown" # sun rose again with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=_simulated_returns, ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL * 4) + async_fire_time_changed(hass) await hass.async_block_till_done() power = hass.states.get("sensor.mydevicename_power_output") assert power is not None @@ -146,8 +156,12 @@ async def test_sensor_dark(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraTimeoutError("No response after 10 seconds"), + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=AuroraError("No response after 10 seconds"), ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL * 6) + async_fire_time_changed(hass) await hass.async_block_till_done() power = hass.states.get("sensor.mydevicename_power_output") assert power.state == "unknown" # should this be 'available'? @@ -160,7 +174,7 @@ async def test_sensor_unknown_error(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraError("another error"), - ): + ), patch("serial.Serial.isOpen", return_value=True): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() From 41626ed500901e4f1d072b02e9c7ddfb20ac6051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C4=8Cerm=C3=A1k?= Date: Wed, 22 Nov 2023 20:00:28 +0100 Subject: [PATCH 667/982] Support for more features on smartthings AC (#99424) * ability to set swing mode on samsung AC * support for windFree mode on samsung AC * Apply suggestions from code review Co-authored-by: G Johansson * suggestion from code reviews * Apply suggestions from code review --------- Co-authored-by: G Johansson --- .../components/smartthings/climate.py | 109 ++++++++++++++++-- tests/components/smartthings/test_climate.py | 55 ++++++++- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 52a02aca745..16558d2c795 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -13,6 +13,10 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -71,6 +75,20 @@ STATE_TO_AC_MODE = { HVACMode.FAN_ONLY: "fanOnly", } +SWING_TO_FAN_OSCILLATION = { + SWING_BOTH: "all", + SWING_HORIZONTAL: "horizontal", + SWING_VERTICAL: "vertical", + SWING_OFF: "fixed", +} + +FAN_OSCILLATION_TO_SWING = { + value: key for key, value in SWING_TO_FAN_OSCILLATION.items() +} + + +WINDFREE = "windFree" + UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) @@ -322,18 +340,34 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) + _hvac_modes: list[HVACMode] - def __init__(self, device): + def __init__(self, device) -> None: """Init the class.""" super().__init__(device) - self._hvac_modes = None + self._hvac_modes = [] + self._attr_preset_mode = None + self._attr_preset_modes = self._determine_preset_modes() + self._attr_swing_modes = self._determine_swing_modes() + self._attr_supported_features = self._determine_supported_features() + + def _determine_supported_features(self) -> ClimateEntityFeature: + features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if self._device.get_capability(Capability.fan_oscillation_mode): + features |= ClimateEntityFeature.SWING_MODE + if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0: + features |= ClimateEntityFeature.PRESET_MODE + return features async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" await self._device.set_fan_mode(fan_mode, set_status=True) + + # setting the fan must reset the preset mode (it deactivates the windFree function) + self._attr_preset_mode = None + # State is set optimistically in the command above, therefore update # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() @@ -407,12 +441,12 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): self._hvac_modes = list(modes) @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._device.status.temperature @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes. Include attributes from the Demand Response Load Control (drlc) @@ -432,12 +466,12 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): return state_attributes @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the fan setting.""" return self._device.status.fan_mode @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" return self._device.status.supported_ac_fan_modes @@ -454,11 +488,62 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): return self._hvac_modes @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._device.status.cooling_setpoint @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit) + return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] + + def _determine_swing_modes(self) -> list[str]: + """Return the list of available swing modes.""" + supported_modes = self._device.status.attributes[ + Attribute.supported_fan_oscillation_modes + ][0] + supported_swings = [ + FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes + ] + return supported_swings + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set swing mode.""" + fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode] + await self._device.set_fan_oscillation_mode(fan_oscillation_mode) + + # setting the fan must reset the preset mode (it deactivates the windFree function) + self._attr_preset_mode = None + + self.async_schedule_update_ha_state(True) + + @property + def swing_mode(self) -> str: + """Return the swing setting.""" + return FAN_OSCILLATION_TO_SWING.get( + self._device.status.fan_oscillation_mode, SWING_OFF + ) + + def _determine_preset_modes(self) -> list[str] | None: + """Return a list of available preset modes.""" + supported_modes = self._device.status.attributes[ + "supportedAcOptionalMode" + ].value + if WINDFREE in supported_modes: + return [WINDFREE] + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set special modes (currently only windFree is supported).""" + result = await self._device.command( + "main", + "custom.airConditionerOptionalMode", + "setAcOptionalMode", + [preset_mode], + ) + if result: + self._device.status.update_attribute_value("acOptionalMode", preset_mode) + + self._attr_preset_mode = preset_mode + + self.async_write_ha_state() diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index ce875190efb..e74d69f04c9 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -15,16 +15,20 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_PRESET_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, HVACAction, HVACMode, ) +from homeassistant.components.climate.const import ATTR_SWING_MODE from homeassistant.components.smartthings import climate from homeassistant.components.smartthings.const import DOMAIN from homeassistant.const import ( @@ -155,6 +159,7 @@ def air_conditioner_fixture(device_factory): Capability.switch, Capability.temperature_measurement, Capability.thermostat_cooling_setpoint, + Capability.fan_oscillation_mode, ], status={ Attribute.air_conditioner_mode: "auto", @@ -182,6 +187,14 @@ def air_conditioner_fixture(device_factory): ], Attribute.switch: "on", Attribute.cooling_setpoint: 23, + "supportedAcOptionalMode": ["windFree"], + Attribute.supported_fan_oscillation_modes: [ + "all", + "horizontal", + "vertical", + "fixed", + ], + Attribute.fan_oscillation_mode: "vertical", }, ) device.status.attributes[Attribute.temperature] = Status(24, "C", None) @@ -303,7 +316,10 @@ async def test_air_conditioner_entity_state( assert state.state == HVACMode.HEAT_COOL assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + == ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE ) assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ HVACMode.COOL, @@ -591,3 +607,40 @@ async def test_entity_and_device_attributes(hass: HomeAssistant, thermostat) -> assert entry.manufacturer == "Generic manufacturer" assert entry.hw_version == "v4.56" assert entry.sw_version == "v7.89" + + +async def test_set_windfree_off(hass: HomeAssistant, air_conditioner) -> None: + """Test if the windfree preset can be turned on and is turned off when fan mode is set.""" + entity_ids = ["climate.air_conditioner"] + air_conditioner.status.update_attribute_value(Attribute.switch, "on") + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_ids, ATTR_PRESET_MODE: "windFree"}, + blocking=True, + ) + state = hass.states.get("climate.air_conditioner") + assert state.attributes[ATTR_PRESET_MODE] == "windFree" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "low"}, + blocking=True, + ) + state = hass.states.get("climate.air_conditioner") + assert not state.attributes[ATTR_PRESET_MODE] + + +async def test_set_swing_mode(hass: HomeAssistant, air_conditioner) -> None: + """Test the fan swing is set successfully.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + entity_ids = ["climate.air_conditioner"] + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: entity_ids, ATTR_SWING_MODE: "vertical"}, + blocking=True, + ) + state = hass.states.get("climate.air_conditioner") + assert state.attributes[ATTR_SWING_MODE] == "vertical" From ae4552eb3e55dad141043ddd213305256c97f218 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 22 Nov 2023 21:02:16 +0200 Subject: [PATCH 668/982] Improve Unifi switch entity unique ID naming function (#104370) --- homeassistant/components/unifi/switch.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 41c1f55a22a..18a3dbc3b90 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -199,6 +199,12 @@ class UnifiSwitchEntityDescription( only_event_for_state_change: bool = False +def _make_unique_id(obj_id: str, type_name: str) -> str: + """Split an object id by the first underscore and interpose the given type.""" + prefix, _, suffix = obj_id.partition("_") + return f"{prefix}-{type_name}-{suffix}" + + ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( UnifiSwitchEntityDescription[Clients, Client]( key="Block client", @@ -256,7 +262,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=False, supported_fn=async_outlet_supports_switching_fn, - unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-outlet-{obj_id.split('_', 1)[1]}", + unique_id_fn=lambda controller, obj_id: _make_unique_id(obj_id, "outlet"), ), UnifiSwitchEntityDescription[PortForwarding, PortForward]( key="Port forward control", @@ -297,7 +303,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.ports[obj_id], should_poll=False, supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, - unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-poe-{obj_id.split('_', 1)[1]}", + unique_id_fn=lambda controller, obj_id: _make_unique_id(obj_id, "poe"), ), UnifiSwitchEntityDescription[Wlans, Wlan]( key="WLAN control", From 968563253f2823e2195c77764ecadcf573861461 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 22 Nov 2023 20:49:21 +0100 Subject: [PATCH 669/982] Bump reolink-aio to 0.8.1 (#104382) --- 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 56a2408eff5..5ffbc2fb186 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.8.0"] + "requirements": ["reolink-aio==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 318cd337874..3b67ff705c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2339,7 +2339,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.8.0 +reolink-aio==0.8.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9584328db18..f47a416cb23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1748,7 +1748,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.8.0 +reolink-aio==0.8.1 # homeassistant.components.rflink rflink==0.0.65 From 3a42bd35e750733dcf5e30c6bf26f13ac27211a7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 22 Nov 2023 21:16:12 +0100 Subject: [PATCH 670/982] Test platform setup errors are notified (#104384) Test setup errors are notified --- tests/test_bootstrap.py | 24 +++++++++++++----------- tests/test_setup.py | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index f5e01e0c97b..c3e25219369 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -719,17 +719,19 @@ async def test_setup_hass_invalid_core_config( event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" - hass = await bootstrap.async_setup_hass( - runner.RuntimeConfig( - config_dir=get_test_config_dir(), - verbose=False, - log_rotate_days=10, - log_file="", - log_no_color=False, - skip_pip=True, - recovery_mode=False, - ), - ) + with patch("homeassistant.config.async_notify_setup_error") as mock_notify: + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=False, + ), + ) + assert len(mock_notify.mock_calls) == 1 assert "recovery_mode" in hass.config.components diff --git a/tests/test_setup.py b/tests/test_setup.py index 66a62511fcb..0f480198c11 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -373,7 +373,9 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: MockPlatform(platform_schema=platform_schema, setup_platform=mock_setup), ) - with assert_setup_component(0, "switch"): + with assert_setup_component(0, "switch"), patch( + "homeassistant.config.async_notify_setup_error" + ) as mock_notify: assert await setup.async_setup_component( hass, "switch", @@ -381,11 +383,14 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert mock_setup.call_count == 0 + assert len(mock_notify.mock_calls) == 1 hass.data.pop(setup.DATA_SETUP) hass.config.components.remove("switch") - with assert_setup_component(0): + with assert_setup_component(0), patch( + "homeassistant.config.async_notify_setup_error" + ) as mock_notify: assert await setup.async_setup_component( hass, "switch", @@ -399,11 +404,14 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert mock_setup.call_count == 0 + assert len(mock_notify.mock_calls) == 1 hass.data.pop(setup.DATA_SETUP) hass.config.components.remove("switch") - with assert_setup_component(1, "switch"): + with assert_setup_component(1, "switch"), patch( + "homeassistant.config.async_notify_setup_error" + ) as mock_notify: assert await setup.async_setup_component( hass, "switch", @@ -411,6 +419,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert mock_setup.call_count == 1 + assert len(mock_notify.mock_calls) == 0 async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: From 3e641b3ef2b0717a61bfb4a1675ebfbe7c11bbda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Wed, 22 Nov 2023 21:38:13 +0100 Subject: [PATCH 671/982] =?UTF-8?q?Add=20Nob=C3=B8=20Hub=20week=20profiles?= =?UTF-8?q?=20and=20global=20override=20(#80866)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * * Nobø Ecohub select entities - Week profiles - Global overrides * Set integration_type * Typing * Remove translations * Translation fixes - Moved strings.select.json into strings.json - Added translation keys for select entities - Use shared translation keys for global override states * Use DeviceInfo object * Revert temperature name - uses device class name * Fix updated checks * Improve error handling (preparing for Silver level) * Review --- .coveragerc | 1 + homeassistant/components/nobo_hub/__init__.py | 31 +--- .../components/nobo_hub/manifest.json | 1 + homeassistant/components/nobo_hub/select.py | 170 ++++++++++++++++++ .../components/nobo_hub/strings.json | 16 ++ 5 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/nobo_hub/select.py diff --git a/.coveragerc b/.coveragerc index 0d7c895f3f3..aed63e42938 100644 --- a/.coveragerc +++ b/.coveragerc @@ -830,6 +830,7 @@ omit = homeassistant/components/noaa_tides/sensor.py homeassistant/components/nobo_hub/__init__.py homeassistant/components/nobo_hub/climate.py + homeassistant/components/nobo_hub/select.py homeassistant/components/nobo_hub/sensor.py homeassistant/components/norway_air/air_quality.py homeassistant/components/notify_events/notify.py diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index bc2c328d647..6c77f98d1b1 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -4,26 +4,12 @@ from __future__ import annotations from pynobo import nobo from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - CONF_IP_ADDRESS, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from .const import ( - ATTR_HARDWARE_VERSION, - ATTR_SERIAL, - ATTR_SOFTWARE_VERSION, - CONF_AUTO_DISCOVERED, - CONF_SERIAL, - DOMAIN, - NOBO_MANUFACTURER, -) +from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -37,17 +23,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) - # Register hub as device - dev_reg = dr.async_get(hass) - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, hub.hub_info[ATTR_SERIAL])}, - manufacturer=NOBO_MANUFACTURER, - name=hub.hub_info[ATTR_NAME], - model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})", - sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], - ) - async def _async_close(event): """Close the Nobø Ecohub socket connection when HA stops.""" await hub.stop() diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index 4e6009ce6d7..9ddbed7dadc 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@echoromeo", "@oyvindwe"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nobo_hub", + "integration_type": "hub", "iot_class": "local_push", "requirements": ["pynobo==1.6.0"] } diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py new file mode 100644 index 00000000000..b386e158420 --- /dev/null +++ b/homeassistant/components/nobo_hub/select.py @@ -0,0 +1,170 @@ +"""Python Control of Nobø Hub - Nobø Energy Control.""" +from __future__ import annotations + +from pynobo import nobo + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_HARDWARE_VERSION, + ATTR_SERIAL, + ATTR_SOFTWARE_VERSION, + CONF_OVERRIDE_TYPE, + DOMAIN, + NOBO_MANUFACTURER, + OVERRIDE_TYPE_NOW, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up any temperature sensors connected to the Nobø Ecohub.""" + + # Setup connection with hub + hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + + override_type = ( + nobo.API.OVERRIDE_TYPE_NOW + if config_entry.options.get(CONF_OVERRIDE_TYPE) == OVERRIDE_TYPE_NOW + else nobo.API.OVERRIDE_TYPE_CONSTANT + ) + + entities: list[SelectEntity] = [ + NoboProfileSelector(zone_id, hub) for zone_id in hub.zones + ] + entities.append(NoboGlobalSelector(hub, override_type)) + async_add_entities(entities, True) + + +class NoboGlobalSelector(SelectEntity): + """Global override selector for Nobø Ecohub.""" + + _attr_has_entity_name = True + _attr_translation_key = "global_override" + _attr_device_class = "nobo_hub__override" + _attr_should_poll = False + _modes = { + nobo.API.OVERRIDE_MODE_NORMAL: "none", + nobo.API.OVERRIDE_MODE_AWAY: "away", + nobo.API.OVERRIDE_MODE_COMFORT: "comfort", + nobo.API.OVERRIDE_MODE_ECO: "eco", + } + _attr_options = list(_modes.values()) + _attr_current_option: str + + def __init__(self, hub: nobo, override_type) -> None: + """Initialize the global override selector.""" + self._nobo = hub + self._attr_unique_id = hub.hub_serial + self._override_type = override_type + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, hub.hub_serial)}, + name=hub.hub_info[ATTR_NAME], + manufacturer=NOBO_MANUFACTURER, + model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})", + sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], + ) + + async def async_added_to_hass(self) -> None: + """Register callback from hub.""" + self._nobo.register_callback(self._after_update) + + async def async_will_remove_from_hass(self) -> None: + """Deregister callback from hub.""" + self._nobo.deregister_callback(self._after_update) + + async def async_select_option(self, option: str) -> None: + """Set override.""" + mode = [k for k, v in self._modes.items() if v == option][0] + try: + await self._nobo.async_create_override( + mode, self._override_type, nobo.API.OVERRIDE_TARGET_GLOBAL + ) + except Exception as exp: + raise HomeAssistantError from exp + + async def async_update(self) -> None: + """Fetch new state data for this zone.""" + self._read_state() + + @callback + def _read_state(self) -> None: + for override in self._nobo.overrides.values(): + if override["target_type"] == nobo.API.OVERRIDE_TARGET_GLOBAL: + self._attr_current_option = self._modes[override["mode"]] + break + + @callback + def _after_update(self, hub) -> None: + self._read_state() + self.async_write_ha_state() + + +class NoboProfileSelector(SelectEntity): + """Week profile selector for Nobø zones.""" + + _attr_translation_key = "week_profile" + _attr_has_entity_name = True + _attr_should_poll = False + _profiles: dict[int, str] = {} + _attr_options: list[str] = [] + _attr_current_option: str + + def __init__(self, zone_id: str, hub: nobo) -> None: + """Initialize the week profile selector.""" + self._id = zone_id + self._nobo = hub + self._attr_unique_id = f"{hub.hub_serial}:{zone_id}:profile" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, + name=hub.zones[zone_id][ATTR_NAME], + via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), + suggested_area=hub.zones[zone_id][ATTR_NAME], + ) + + async def async_added_to_hass(self) -> None: + """Register callback from hub.""" + self._nobo.register_callback(self._after_update) + + async def async_will_remove_from_hass(self) -> None: + """Deregister callback from hub.""" + self._nobo.deregister_callback(self._after_update) + + async def async_select_option(self, option: str) -> None: + """Set week profile.""" + week_profile_id = [k for k, v in self._profiles.items() if v == option][0] + try: + await self._nobo.async_update_zone( + self._id, week_profile_id=week_profile_id + ) + except Exception as exp: + raise HomeAssistantError from exp + + async def async_update(self) -> None: + """Fetch new state data for this zone.""" + self._read_state() + + @callback + def _read_state(self) -> None: + self._profiles = { + profile["week_profile_id"]: profile["name"].replace("\xa0", " ") + for profile in self._nobo.week_profiles.values() + } + self._attr_options = sorted(self._profiles.values()) + self._attr_current_option = self._profiles[ + self._nobo.zones[self._id]["week_profile_id"] + ] + + @callback + def _after_update(self, hub) -> None: + self._read_state() + self.async_write_ha_state() diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index cfa339c98df..28be01862e9 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -40,5 +40,21 @@ "description": "Select override type \"Now\" to end override on next week profile change." } } + }, + "entity": { + "select": { + "global_override": { + "name": "global override", + "state": { + "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", + "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", + "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" + } + }, + "week_profile": { + "name": "week profile" + } + } } } From a3c0f36592d04e2ee999c85745722687a820bef0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 22 Nov 2023 22:54:48 +0100 Subject: [PATCH 672/982] Add Reolink serial number (#104383) * Add Reolink serial number * fix tests --- homeassistant/components/reolink/entity.py | 1 + tests/components/reolink/conftest.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 4b9689e2652..5c874fb7ff9 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -42,6 +42,7 @@ class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]) manufacturer=self._host.api.manufacturer, hw_version=self._host.api.hardware_version, sw_version=self._host.api.sw_version, + serial_number=self._host.api.uid, configuration_url=self._conf_url, ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2a6fd0fecd3..e8980b615e2 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -20,6 +20,7 @@ TEST_PASSWORD = "password" TEST_PASSWORD2 = "new_password" TEST_MAC = "ab:cd:ef:gh:ij:kl" TEST_MAC2 = "12:34:56:78:9a:bc" +TEST_UID = "ABC1234567D89EFG" TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_NVR_NAME2 = "test2_reolink_name" @@ -53,6 +54,7 @@ def reolink_connect_class( host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True host_mock.mac_address = TEST_MAC + host_mock.uid = TEST_UID host_mock.onvif_enabled = True host_mock.rtmp_enabled = True host_mock.rtsp_enabled = True From a59076d140e316af686a9ed705281e0d6255ac9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Nov 2023 23:27:17 +0100 Subject: [PATCH 673/982] Speed up ESPHome connection setup (#104304) --- .../components/esphome/bluetooth/__init__.py | 49 ++++++++---- homeassistant/components/esphome/manager.py | 77 +++++++++++++------ 2 files changed, 86 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 9ef298145d3..6936afac714 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -1,8 +1,11 @@ """Bluetooth support for esphome.""" from __future__ import annotations +import asyncio +from collections.abc import Coroutine from functools import partial import logging +from typing import Any from aioesphomeapi import APIClient, BluetoothProxyFeature @@ -43,6 +46,13 @@ def _async_can_connect( return can_connect +@hass_callback +def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: + """Cancel all the callbacks on unload.""" + for callback in unload_callbacks: + callback() + + async def async_connect_scanner( hass: HomeAssistant, entry: ConfigEntry, @@ -92,27 +102,36 @@ async def async_connect_scanner( hass, source, entry.title, new_info_callback, connector, connectable ) client_data.scanner = scanner + coros: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] + # These calls all return a callback that can be used to unsubscribe + # but we never unsubscribe so we don't care about the return value + if connectable: # If its connectable be sure not to register the scanner # until we know the connection is fully setup since otherwise # there is a race condition where the connection can fail - await cli.subscribe_bluetooth_connections_free( - bluetooth_device.async_update_ble_connection_limits + coros.append( + cli.subscribe_bluetooth_connections_free( + bluetooth_device.async_update_ble_connection_limits + ) ) - unload_callbacks = [ - async_register_scanner(hass, scanner, connectable), - scanner.async_setup(), - ] + if feature_flags & BluetoothProxyFeature.RAW_ADVERTISEMENTS: - await cli.subscribe_bluetooth_le_raw_advertisements( - scanner.async_on_raw_advertisements + coros.append( + cli.subscribe_bluetooth_le_raw_advertisements( + scanner.async_on_raw_advertisements + ) ) else: - await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) + coros.append( + cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) + ) - @hass_callback - def _async_unload() -> None: - for callback in unload_callbacks: - callback() - - return _async_unload + await asyncio.gather(*coros) + return partial( + _async_unload, + [ + async_register_scanner(hass, scanner, connectable), + scanner.async_setup(), + ], + ) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 8282940a71d..85c311ecc81 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Coroutine import logging from typing import TYPE_CHECKING, Any, NamedTuple @@ -10,6 +11,7 @@ from aioesphomeapi import ( APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, + EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -26,7 +28,14 @@ import voluptuous as vol from homeassistant.components import tag, zeroconf from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, CONF_MODE, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HomeAssistant, + ServiceCall, + State, + callback, +) from homeassistant.exceptions import TemplateError from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv @@ -372,13 +381,20 @@ class ESPHomeManager: stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id try: - device_info = await cli.device_info() + results = await asyncio.gather( + cli.device_info(), + cli.list_entities_services(), + ) except APIConnectionError as err: _LOGGER.warning("Error getting device info for %s: %s", self.host, err) # Re-connection logic will trigger after this await cli.disconnect() return + device_info: EsphomeDeviceInfo = results[0] + entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1] + entity_infos, services = entity_infos_services + device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac # @@ -439,44 +455,55 @@ class ESPHomeManager: if device_info.name: reconnect_logic.name = device_info.name + self.device_id = _async_setup_device_registry(hass, entry, entry_data) + entry_data.async_update_device_state(hass) + await asyncio.gather( + entry_data.async_update_static_infos( + hass, entry, entity_infos, device_info.mac_address + ), + _setup_services(hass, entry_data, services), + ) + + setup_coros_with_disconnect_callbacks: list[ + Coroutine[Any, Any, CALLBACK_TYPE] + ] = [] if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): - entry_data.disconnect_callbacks.add( - await async_connect_scanner( + setup_coros_with_disconnect_callbacks.append( + async_connect_scanner( hass, entry, cli, entry_data, self.domain_data.bluetooth_cache ) ) - self.device_id = _async_setup_device_registry(hass, entry, entry_data) - entry_data.async_update_device_state(hass) + if device_info.voice_assistant_version: + setup_coros_with_disconnect_callbacks.append( + cli.subscribe_voice_assistant( + self._handle_pipeline_start, + self._handle_pipeline_stop, + ) + ) try: - entity_infos, services = await cli.list_entities_services() - await entry_data.async_update_static_infos( - hass, entry, entity_infos, device_info.mac_address - ) - await _setup_services(hass, entry_data, services) - await asyncio.gather( + setup_results = await asyncio.gather( + *setup_coros_with_disconnect_callbacks, cli.subscribe_states(entry_data.async_update_state), cli.subscribe_service_calls(self.async_on_service_call), cli.subscribe_home_assistant_states(self.async_on_state_subscription), ) - - if device_info.voice_assistant_version: - entry_data.disconnect_callbacks.add( - await cli.subscribe_voice_assistant( - self._handle_pipeline_start, - self._handle_pipeline_stop, - ) - ) - - hass.async_create_task(entry_data.async_save_to_store()) except APIConnectionError as err: _LOGGER.warning("Error getting initial data for %s: %s", self.host, err) # Re-connection logic will trigger after this await cli.disconnect() - else: - _async_check_firmware_version(hass, device_info, entry_data.api_version) - _async_check_using_api_password(hass, device_info, bool(self.password)) + return + + for result_idx in range(len(setup_coros_with_disconnect_callbacks)): + cancel_callback = setup_results[result_idx] + if TYPE_CHECKING: + assert cancel_callback is not None + entry_data.disconnect_callbacks.add(cancel_callback) + + hass.async_create_task(entry_data.async_save_to_store()) + _async_check_firmware_version(hass, device_info, entry_data.api_version) + _async_check_using_api_password(hass, device_info, bool(self.password)) async def on_disconnect(self, expected_disconnect: bool) -> None: """Run disconnect callbacks on API disconnect.""" From 556e72abf8e841471f5a9b6a994cd4203b1c7118 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 23 Nov 2023 07:29:28 +0100 Subject: [PATCH 674/982] Add number entities to adjust heating curve in ViCare integration (#103901) * add number entity * use static constraints * use async execute job * add number platform * Update .coveragerc * remove unused code parts * add types * add missing return type * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Apply suggestions from code review Co-authored-by: Jan-Philipp Benecke * fix docstrings * fix variable names * add unit of measurement * remove obsolete unique id handling * remove hass from constructor * inline _entities_from_descriptions function * fix return type * rename variable * Apply suggestions from code review Co-authored-by: Jan-Philipp Benecke * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Jan-Philipp Benecke --- .coveragerc | 1 + homeassistant/components/vicare/const.py | 3 +- homeassistant/components/vicare/number.py | 164 ++++++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vicare/number.py diff --git a/.coveragerc b/.coveragerc index aed63e42938..684498f33a9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1466,6 +1466,7 @@ omit = homeassistant/components/vicare/button.py homeassistant/components/vicare/climate.py homeassistant/components/vicare/entity.py + homeassistant/components/vicare/number.py homeassistant/components/vicare/sensor.py homeassistant/components/vicare/utils.py homeassistant/components/vicare/water_heater.py diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index 546f18985e8..3ed81ab587a 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -6,10 +6,11 @@ from homeassistant.const import Platform, UnitOfEnergy, UnitOfVolume DOMAIN = "vicare" PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, - Platform.BINARY_SENSOR, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py new file mode 100644 index 00000000000..17017d00def --- /dev/null +++ b/homeassistant/components/vicare/number.py @@ -0,0 +1,164 @@ +"""Number for ViCare.""" +from __future__ import annotations + +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass +import logging +from typing import Any + +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) +from PyViCare.PyViCareUtils import ( + PyViCareInvalidDataError, + PyViCareNotSupportedFeatureError, + PyViCareRateLimitError, +) +from requests.exceptions import ConnectionError as RequestConnectionError + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ViCareRequiredKeysMixin +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .entity import ViCareEntity +from .utils import is_supported + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): + """Describes ViCare number entity.""" + + value_setter: Callable[[PyViCareDevice, float], Any] | None = None + + +CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( + ViCareNumberEntityDescription( + key="heating curve shift", + name="Heating curve shift", + icon="mdi:plus-minus-variant", + entity_category=EntityCategory.CONFIG, + value_getter=lambda api: api.getHeatingCurveShift(), + value_setter=lambda api, shift: ( + api.setHeatingCurve(shift, api.getHeatingCurveSlope()) + ), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_min_value=-13, + native_max_value=40, + native_step=1, + ), + ViCareNumberEntityDescription( + key="heating curve slope", + name="Heating curve slope", + icon="mdi:slope-uphill", + entity_category=EntityCategory.CONFIG, + value_getter=lambda api: api.getHeatingCurveSlope(), + value_setter=lambda api, slope: ( + api.setHeatingCurve(api.getHeatingCurveShift(), slope) + ), + native_min_value=0.2, + native_max_value=3.5, + native_step=0.1, + ), +) + + +def _build_entity( + name: str, + vicare_api: PyViCareHeatingDeviceWithComponent, + device_config: PyViCareDeviceConfig, + entity_description: ViCareNumberEntityDescription, +) -> ViCareNumber | None: + """Create a ViCare number entity.""" + _LOGGER.debug("Found device %s", name) + if is_supported(name, entity_description, vicare_api): + return ViCareNumber( + name, + vicare_api, + device_config, + entity_description, + ) + return None + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create the ViCare number devices.""" + api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + + entities: list[ViCareNumber] = [] + try: + for circuit in api.circuits: + suffix = "" + if len(api.circuits) > 1: + suffix = f" {circuit.id}" + for description in CIRCUIT_ENTITY_DESCRIPTIONS: + entity = await hass.async_add_executor_job( + _build_entity, + f"{description.name}{suffix}", + circuit, + hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], + description, + ) + if entity is not None: + entities.append(entity) + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No circuits found") + + async_add_entities(entities) + + +class ViCareNumber(ViCareEntity, NumberEntity): + """Representation of a ViCare number.""" + + entity_description: ViCareNumberEntityDescription + + def __init__( + self, + name: str, + api: PyViCareHeatingDeviceWithComponent, + device_config: PyViCareDeviceConfig, + description: ViCareNumberEntityDescription, + ) -> None: + """Initialize the number.""" + super().__init__(device_config, api, description.key) + self.entity_description = description + self._attr_name = name + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._attr_native_value is not None + + def set_native_value(self, value: float) -> None: + """Set new value.""" + if self.entity_description.value_setter: + self.entity_description.value_setter(self._api, value) + self.async_write_ha_state() + + def update(self) -> None: + """Update state of number.""" + try: + with suppress(PyViCareNotSupportedFeatureError): + self._attr_native_value = self.entity_description.value_getter( + self._api + ) + except RequestConnectionError: + _LOGGER.error("Unable to retrieve data from ViCare server") + except ValueError: + _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) + except PyViCareInvalidDataError as invalid_data_exception: + _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) From eaba2c7dc13a90b75745e508c06ef342b53599fe Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 23 Nov 2023 07:53:50 +0100 Subject: [PATCH 675/982] Update p1monitor lib to v3.0.0 (#104395) --- .../components/p1_monitor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/p1_monitor/test_diagnostics.py | 22 +++++++++---------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json index 3ed5589e577..0dfe1f3a46c 100644 --- a/homeassistant/components/p1_monitor/manifest.json +++ b/homeassistant/components/p1_monitor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["p1monitor"], "quality_scale": "platinum", - "requirements": ["p1monitor==2.1.1"] + "requirements": ["p1monitor==3.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3b67ff705c4..b090f5ff6b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1426,7 +1426,7 @@ orvibo==1.1.1 ovoenergy==1.2.0 # homeassistant.components.p1_monitor -p1monitor==2.1.1 +p1monitor==3.0.0 # homeassistant.components.mqtt paho-mqtt==1.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f47a416cb23..e40aed8d023 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1096,7 +1096,7 @@ oralb-ble==0.17.6 ovoenergy==1.2.0 # homeassistant.components.p1_monitor -p1monitor==2.1.1 +p1monitor==3.0.0 # homeassistant.components.mqtt paho-mqtt==1.6.1 diff --git a/tests/components/p1_monitor/test_diagnostics.py b/tests/components/p1_monitor/test_diagnostics.py index 47f43dd3401..55d4ccc5e67 100644 --- a/tests/components/p1_monitor/test_diagnostics.py +++ b/tests/components/p1_monitor/test_diagnostics.py @@ -35,12 +35,12 @@ async def test_diagnostics( "energy_production_low": 1432.279, }, "phases": { - "voltage_phase_l1": "233.6", - "voltage_phase_l2": "0.0", - "voltage_phase_l3": "233.0", - "current_phase_l1": "1.6", - "current_phase_l2": "4.44", - "current_phase_l3": "3.51", + "voltage_phase_l1": 233.6, + "voltage_phase_l2": 0.0, + "voltage_phase_l3": 233.0, + "current_phase_l1": 1.6, + "current_phase_l2": 4.44, + "current_phase_l3": 3.51, "power_consumed_phase_l1": 315, "power_consumed_phase_l2": 0, "power_consumed_phase_l3": 624, @@ -49,11 +49,11 @@ async def test_diagnostics( "power_produced_phase_l3": 0, }, "settings": { - "gas_consumption_price": "0.64", - "energy_consumption_price_high": "0.20522", - "energy_consumption_price_low": "0.20522", - "energy_production_price_high": "0.20522", - "energy_production_price_low": "0.20522", + "gas_consumption_price": 0.64, + "energy_consumption_price_high": 0.20522, + "energy_consumption_price_low": 0.20522, + "energy_production_price_high": 0.20522, + "energy_production_price_low": 0.20522, }, "watermeter": { "consumption_day": 112.0, From 5623834b37f2ed11a5b54478baddf328f10dc9d6 Mon Sep 17 00:00:00 2001 From: deosrc Date: Thu, 23 Nov 2023 07:10:10 +0000 Subject: [PATCH 676/982] Add Netatmo temperature services (#104124) * Update datetime strings to match input_datetime integration * Add netatmo service to set temperature * Add netatmo service to clear temperature setting * Fix formatting * Add tests for new services * Fix mypy error * Fix formatting * Fix formatting * Apply suggestions from code review (WIP) Co-authored-by: G Johansson * Complete changes from review suggestions * Fix build error * Add service to set temperature for time period * Expand and fix test * Replace duplicated strings with links --------- Co-authored-by: G Johansson --- homeassistant/components/netatmo/climate.py | 71 +++++- homeassistant/components/netatmo/const.py | 5 + .../components/netatmo/services.yaml | 50 +++++ homeassistant/components/netatmo/strings.json | 38 +++- tests/components/netatmo/test_climate.py | 203 ++++++++++++++++++ 5 files changed, 363 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index a14cadf45c4..5a05818d3f2 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -39,6 +39,8 @@ from .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, + ATTR_TARGET_TEMPERATURE, + ATTR_TIME_PERIOD, CONF_URL_ENERGY, DATA_SCHEDULES, DOMAIN, @@ -47,8 +49,11 @@ from .const import ( EVENT_TYPE_SET_POINT, EVENT_TYPE_THERM_MODE, NETATMO_CREATE_CLIMATE, + SERVICE_CLEAR_TEMPERATURE_SETTING, SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, SERVICE_SET_SCHEDULE, + SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, + SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom from .netatmo_entity_base import NetatmoBase @@ -143,6 +148,34 @@ async def async_setup_entry( }, "_async_service_set_preset_mode_with_end_datetime", ) + platform.async_register_entity_service( + SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, + { + vol.Required(ATTR_TARGET_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Range(min=7, max=30) + ), + vol.Required(ATTR_END_DATETIME): cv.datetime, + }, + "_async_service_set_temperature_with_end_datetime", + ) + platform.async_register_entity_service( + SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, + { + vol.Required(ATTR_TARGET_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Range(min=7, max=30) + ), + vol.Required(ATTR_TIME_PERIOD): vol.All( + cv.time_period, + cv.positive_timedelta, + ), + }, + "_async_service_set_temperature_with_time_period", + ) + platform.async_register_entity_service( + SERVICE_CLEAR_TEMPERATURE_SETTING, + {}, + "_async_service_clear_temperature_setting", + ) class NetatmoThermostat(NetatmoBase, ClimateEntity): @@ -441,12 +474,48 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): mode=PRESET_MAP_NETATMO[preset_mode], end_time=end_timestamp ) _LOGGER.debug( - "Setting %s preset to %s with optional end datetime to %s", + "Setting %s preset to %s with end datetime %s", self._room.home.entity_id, preset_mode, end_timestamp, ) + async def _async_service_set_temperature_with_end_datetime( + self, **kwargs: Any + ) -> None: + target_temperature = kwargs[ATTR_TARGET_TEMPERATURE] + end_datetime = kwargs[ATTR_END_DATETIME] + end_timestamp = int(dt_util.as_timestamp(end_datetime)) + + _LOGGER.debug( + "Setting %s to target temperature %s with end datetime %s", + self._room.entity_id, + target_temperature, + end_timestamp, + ) + await self._room.async_therm_manual(target_temperature, end_timestamp) + + async def _async_service_set_temperature_with_time_period( + self, **kwargs: Any + ) -> None: + target_temperature = kwargs[ATTR_TARGET_TEMPERATURE] + time_period = kwargs[ATTR_TIME_PERIOD] + + _LOGGER.debug( + "Setting %s to target temperature %s with time period %s", + self._room.entity_id, + target_temperature, + time_period, + ) + + now_timestamp = dt_util.as_timestamp(dt_util.utcnow()) + end_timestamp = int(now_timestamp + time_period.seconds) + await self._room.async_therm_manual(target_temperature, end_timestamp) + + async def _async_service_clear_temperature_setting(self, **kwargs: Any) -> None: + _LOGGER.debug("Clearing %s temperature setting", self._room.entity_id) + await self._room.async_therm_home() + @property def device_info(self) -> DeviceInfo: """Return the device info for the thermostat.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 8a281d4d4a2..3fe456dd657 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -89,12 +89,17 @@ ATTR_PSEUDO = "pseudo" ATTR_SCHEDULE_ID = "schedule_id" ATTR_SCHEDULE_NAME = "schedule_name" ATTR_SELECTED_SCHEDULE = "selected_schedule" +ATTR_TARGET_TEMPERATURE = "target_temperature" +ATTR_TIME_PERIOD = "time_period" +SERVICE_CLEAR_TEMPERATURE_SETTING = "clear_temperature_setting" SERVICE_SET_CAMERA_LIGHT = "set_camera_light" SERVICE_SET_PERSON_AWAY = "set_person_away" SERVICE_SET_PERSONS_HOME = "set_persons_home" SERVICE_SET_SCHEDULE = "set_schedule" SERVICE_SET_PRESET_MODE_WITH_END_DATETIME = "set_preset_mode_with_end_datetime" +SERVICE_SET_TEMPERATURE_WITH_END_DATETIME = "set_temperature_with_end_datetime" +SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD = "set_temperature_with_time_period" # Climate events EVENT_TYPE_CANCEL_SET_POINT = "cancel_set_point" diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index 228f84f175d..cab0528199d 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -46,6 +46,56 @@ set_preset_mode_with_end_datetime: selector: datetime: +set_temperature_with_end_datetime: + target: + entity: + integration: netatmo + domain: climate + fields: + target_temperature: + required: true + example: "19.5" + selector: + number: + min: 7 + max: 30 + step: 0.5 + end_datetime: + required: true + example: '"2019-04-20 05:04:20"' + selector: + datetime: + +set_temperature_with_time_period: + target: + entity: + integration: netatmo + domain: climate + fields: + target_temperature: + required: true + example: "19.5" + selector: + number: + min: 7 + max: 30 + step: 0.5 + time_period: + required: true + default: + hours: 3 + minutes: 0 + seconds: 0 + days: 0 + selector: + duration: + +clear_temperature_setting: + target: + entity: + integration: netatmo + domain: climate + set_persons_home: target: entity: diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index bdb51808852..e504b27b599 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -121,7 +121,7 @@ "description": "Unregisters the webhook from the Netatmo backend." }, "set_preset_mode_with_end_datetime": { - "name": "Set preset mode with end datetime", + "name": "Set preset mode with end date & time", "description": "Sets the preset mode for a Netatmo climate device. The preset mode must match a preset mode configured at Netatmo.", "fields": { "preset_mode": { @@ -129,10 +129,42 @@ "description": "Climate preset mode such as Schedule, Away or Frost Guard." }, "end_datetime": { - "name": "End datetime", - "description": "Datetime for until when the preset will be active." + "name": "End date & time", + "description": "Date & time the preset will be active until." } } + }, + "set_temperature_with_end_datetime": { + "name": "Set temperature with end date & time", + "description": "Sets the target temperature for a Netatmo climate device with an end date & time.", + "fields": { + "target_temperature": { + "name": "Target temperature", + "description": "The target temperature for the device." + }, + "end_datetime": { + "name": "[%key:component::netatmo::services::set_preset_mode_with_end_datetime::fields::end_datetime::name%]", + "description": "Date & time the target temperature will be active until." + } + } + }, + "set_temperature_with_time_period": { + "name": "Set temperature with time period", + "description": "Sets the target temperature for a Netatmo climate device with time period.", + "fields": { + "target_temperature": { + "name": "[%key:component::netatmo::services::set_temperature_with_end_datetime::fields::target_temperature::name%]", + "description": "[%key:component::netatmo::services::set_temperature_with_end_datetime::fields::target_temperature::description%]" + }, + "time_period": { + "name": "Time period", + "description": "The time period which the temperature setting will be active for." + } + } + }, + "clear_temperature_setting": { + "name": "Clear temperature setting", + "description": "Clears any temperature setting for a Netatmo climate device reverting it to the current preset or schedule." } } } diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 99000403a38..848aad331bd 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -22,8 +22,14 @@ from homeassistant.components.netatmo.climate import PRESET_FROST_GUARD, PRESET_ from homeassistant.components.netatmo.const import ( ATTR_END_DATETIME, ATTR_SCHEDULE_NAME, + ATTR_TARGET_TEMPERATURE, + ATTR_TIME_PERIOD, + DOMAIN as NETATMO_DOMAIN, + SERVICE_CLEAR_TEMPERATURE_SETTING, SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, SERVICE_SET_SCHEDULE, + SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, + SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant @@ -359,6 +365,203 @@ async def test_service_preset_modes_thermostat( assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 30 +async def test_service_set_temperature_with_end_datetime( + hass: HomeAssistant, config_entry, netatmo_auth +) -> None: + """Test service setting temperature with an end datetime.""" + with selected_platforms(["climate"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + assert hass.states.get(climate_entity_livingroom).state == "auto" + + # Test service setting the temperature without an end datetime + await hass.services.async_call( + NETATMO_DOMAIN, + SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_TARGET_TEMPERATURE: 25, + ATTR_END_DATETIME: "2023-11-17 12:23:00", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Test webhook room mode change to "manual" + response = { + "room_id": "2746182631", + "home": { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "country": "DE", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "therm_setpoint_mode": "manual", + "therm_setpoint_temperature": 25, + "therm_setpoint_end_time": 1612749189, + } + ], + "modules": [ + {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} + ], + }, + "mode": "manual", + "event_type": "set_point", + "push_type": "display_change", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "heat" + assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 25 + + +async def test_service_set_temperature_with_time_period( + hass: HomeAssistant, config_entry, netatmo_auth +) -> None: + """Test service setting temperature with an end datetime.""" + with selected_platforms(["climate"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + assert hass.states.get(climate_entity_livingroom).state == "auto" + + # Test service setting the temperature without an end datetime + await hass.services.async_call( + NETATMO_DOMAIN, + SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_TARGET_TEMPERATURE: 25, + ATTR_TIME_PERIOD: "02:24:00", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Test webhook room mode change to "manual" + response = { + "room_id": "2746182631", + "home": { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "country": "DE", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "therm_setpoint_mode": "manual", + "therm_setpoint_temperature": 25, + "therm_setpoint_end_time": 1612749189, + } + ], + "modules": [ + {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} + ], + }, + "mode": "manual", + "event_type": "set_point", + "push_type": "display_change", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "heat" + assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 25 + + +async def test_service_clear_temperature_setting( + hass: HomeAssistant, config_entry, netatmo_auth +) -> None: + """Test service clearing temperature setting.""" + with selected_platforms(["climate"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + assert hass.states.get(climate_entity_livingroom).state == "auto" + + # Simulate a room thermostat change to manual boost + response = { + "room_id": "2746182631", + "home": { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "country": "DE", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "therm_setpoint_mode": "manual", + "therm_setpoint_temperature": 25, + "therm_setpoint_end_time": 1612749189, + } + ], + "modules": [ + {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} + ], + }, + "mode": "manual", + "event_type": "set_point", + "push_type": "display_change", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "heat" + assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 25 + + # Test service setting the temperature without an end datetime + await hass.services.async_call( + NETATMO_DOMAIN, + SERVICE_CLEAR_TEMPERATURE_SETTING, + {ATTR_ENTITY_ID: climate_entity_livingroom}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test webhook room mode change to "home" + response = { + "room_id": "2746182631", + "home": { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "country": "DE", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "therm_setpoint_mode": "home", + } + ], + "modules": [ + {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} + ], + }, + "mode": "home", + "event_type": "cancel_set_point", + "push_type": "display_change", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "auto" + + async def test_webhook_event_handling_no_data( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: From 0b213c6732659b950d2081521731e6aae0c69b6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Nov 2023 08:20:50 +0100 Subject: [PATCH 677/982] Bump dessant/lock-threads from 5.0.0 to 5.0.1 (#104403) --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index b4fedc57218..fb5deb2958f 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5.0.0 + - uses: dessant/lock-threads@v5.0.1 with: github-token: ${{ github.token }} issue-inactive-days: "30" From 32aa1aaec26d74d18bc43b4aff8da3880f255432 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Thu, 23 Nov 2023 08:35:30 +0100 Subject: [PATCH 678/982] Add pvpc hourly pricing optional API Token support (#85767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🍱 Add new fixture for PVPC data from authenticated API call and update mocked server responses when data is not available (now returns a 200 OK with empty data) * ✨ Implement optional API token in config-flow + options to make the data download from an authenticated path in ESIOS server As this is an *alternative* access, and current public path works for the PVPC, no user (current or new) is compelled to obtain a token, and it can be enabled anytime in options, or doing the setup again When enabling the token, it is verified (or "invalid_auth" error), and a 'reauth' flow is implemented, which can change or disable the token if it starts failing. The 1st step of config/options flow adds a bool to enable this private access, - if unchecked (default), entry is set for public access (like before) - if checked, a 2nd step opens to input the token, with instructions of how to get one (with a direct link to create a 'request email'). If the token is valid, the entry is set for authenticated access The 'reauth' flow shows the boolean flag so the user could disable a bad token by unchecking the boolean flag 'use_api_token' * 🌐 Update strings for config/options flows * ✅ Adapt tests to check API token option and add test_reauth * 🎨 Link new strings to those in config-flow * 🐛 tests: Fix mocked date-change with async_fire_time_changed * ♻️ Remove storage of flag 'use_api_token' in config entry leaving it only to enable/disable the optional token in the config-flow * ♻️ Adjust async_update_options --- .../pvpc_hourly_pricing/__init__.py | 35 +- .../pvpc_hourly_pricing/config_flow.py | 197 +++- .../components/pvpc_hourly_pricing/const.py | 10 +- .../pvpc_hourly_pricing/strings.json | 33 +- .../pvpc_hourly_pricing/conftest.py | 33 +- .../PRICES_ESIOS_1001_2023_01_06.json | 1007 +++++++++++++++++ .../pvpc_hourly_pricing/test_config_flow.py | 113 +- 7 files changed, 1377 insertions(+), 51 deletions(-) create mode 100644 tests/components/pvpc_hourly_pricing/fixtures/PRICES_ESIOS_1001_2023_01_06.json diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 808ff1b4cc4..7071000ffd9 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -2,38 +2,21 @@ from datetime import timedelta import logging -from aiopvpc import DEFAULT_POWER_KW, TARIFFS, EsiosApiData, PVPCData -import voluptuous as vol +from aiopvpc import BadApiTokenAuthError, EsiosApiData, PVPCData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import ( - ATTR_POWER, - ATTR_POWER_P3, - ATTR_TARIFF, - DEFAULT_NAME, - DOMAIN, - PLATFORMS, -) +from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN _LOGGER = logging.getLogger(__name__) -_DEFAULT_TARIFF = TARIFFS[0] -VALID_POWER = vol.All(vol.Coerce(float), vol.Range(min=1.0, max=15.0)) -VALID_TARIFF = vol.In(TARIFFS) -UI_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(ATTR_TARIFF, default=_DEFAULT_TARIFF): VALID_TARIFF, - vol.Required(ATTR_POWER, default=DEFAULT_POWER_KW): VALID_POWER, - vol.Required(ATTR_POWER_P3, default=DEFAULT_POWER_KW): VALID_POWER, - } -) +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -52,7 +35,7 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" if any( entry.data.get(attrib) != entry.options.get(attrib) - for attrib in (ATTR_POWER, ATTR_POWER_P3) + for attrib in (ATTR_POWER, ATTR_POWER_P3, CONF_API_TOKEN) ): # update entry replacing data with new options hass.config_entries.async_update_entry( @@ -80,6 +63,7 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): local_timezone=hass.config.time_zone, power=entry.data[ATTR_POWER], power_valley=entry.data[ATTR_POWER_P3], + api_token=entry.data.get(CONF_API_TOKEN), ) super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) @@ -93,7 +77,10 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): async def _async_update_data(self) -> EsiosApiData: """Update electricity prices from the ESIOS API.""" - api_data = await self.api.async_update_all(self.data, dt_util.utcnow()) + try: + api_data = await self.api.async_update_all(self.data, dt_util.utcnow()) + except BadApiTokenAuthError as exc: + raise ConfigEntryAuthFailed from exc if ( not api_data or not api_data.sensors diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index 9412aa2e97d..a98b9faf56e 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -1,22 +1,49 @@ """Config flow for pvpc_hourly_pricing.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any +from aiopvpc import DEFAULT_POWER_KW, PVPCData import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util -from . import CONF_NAME, UI_CONFIG_SCHEMA, VALID_POWER -from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN +from .const import ( + ATTR_POWER, + ATTR_POWER_P3, + ATTR_TARIFF, + CONF_USE_API_TOKEN, + DEFAULT_NAME, + DEFAULT_TARIFF, + DOMAIN, + VALID_POWER, + VALID_TARIFF, +) + +_MAIL_TO_LINK = ( + "[consultasios@ree.es]" + "(mailto:consultasios@ree.es?subject=Personal%20token%20request)" +) class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle config flow for `pvpc_hourly_pricing`.""" VERSION = 1 + _name: str | None = None + _tariff: str | None = None + _power: float | None = None + _power_p3: float | None = None + _use_api_token: bool = False + _api_token: str | None = None + _api: PVPCData | None = None + _reauth_entry: config_entries.ConfigEntry | None = None @staticmethod @callback @@ -33,36 +60,180 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: await self.async_set_unique_id(user_input[ATTR_TARIFF]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + if not user_input[CONF_USE_API_TOKEN]: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_NAME: user_input[CONF_NAME], + ATTR_TARIFF: user_input[ATTR_TARIFF], + ATTR_POWER: user_input[ATTR_POWER], + ATTR_POWER_P3: user_input[ATTR_POWER_P3], + CONF_API_TOKEN: None, + }, + ) - return self.async_show_form(step_id="user", data_schema=UI_CONFIG_SCHEMA) + self._name = user_input[CONF_NAME] + self._tariff = user_input[ATTR_TARIFF] + self._power = user_input[ATTR_POWER] + self._power_p3 = user_input[ATTR_POWER_P3] + self._use_api_token = user_input[CONF_USE_API_TOKEN] + return self.async_show_form( + step_id="api_token", + data_schema=vol.Schema( + {vol.Required(CONF_API_TOKEN, default=self._api_token): str} + ), + description_placeholders={"mail_to_link": _MAIL_TO_LINK}, + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(ATTR_TARIFF, default=DEFAULT_TARIFF): VALID_TARIFF, + vol.Required(ATTR_POWER, default=DEFAULT_POWER_KW): VALID_POWER, + vol.Required(ATTR_POWER_P3, default=DEFAULT_POWER_KW): VALID_POWER, + vol.Required(CONF_USE_API_TOKEN, default=False): bool, + } + ) + return self.async_show_form(step_id="user", data_schema=data_schema) + + async def async_step_api_token(self, user_input: dict[str, Any]) -> FlowResult: + """Handle optional step to define API token for extra sensors.""" + self._api_token = user_input[CONF_API_TOKEN] + return await self._async_verify( + "api_token", + data_schema=vol.Schema( + {vol.Required(CONF_API_TOKEN, default=self._api_token): str} + ), + ) + + async def _async_verify(self, step_id: str, data_schema: vol.Schema) -> FlowResult: + """Attempt to verify the provided configuration.""" + errors: dict[str, str] = {} + auth_ok = True + if self._use_api_token: + if not self._api: + self._api = PVPCData(session=async_get_clientsession(self.hass)) + auth_ok = await self._api.check_api_token(dt_util.utcnow(), self._api_token) + if not auth_ok: + errors["base"] = "invalid_auth" + return self.async_show_form( + step_id=step_id, + data_schema=data_schema, + errors=errors, + description_placeholders={"mail_to_link": _MAIL_TO_LINK}, + ) + + data = { + CONF_NAME: self._name, + ATTR_TARIFF: self._tariff, + ATTR_POWER: self._power, + ATTR_POWER_P3: self._power_p3, + CONF_API_TOKEN: self._api_token if self._use_api_token else None, + } + if self._reauth_entry: + self.hass.config_entries.async_update_entry(self._reauth_entry, data=data) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + assert self._name is not None + return self.async_create_entry(title=self._name, data=data) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with ESIOS Token.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self._api_token = entry_data.get(CONF_API_TOKEN) + self._use_api_token = self._api_token is not None + self._name = entry_data[CONF_NAME] + self._tariff = entry_data[ATTR_TARIFF] + self._power = entry_data[ATTR_POWER] + self._power_p3 = entry_data[ATTR_POWER_P3] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + data_schema = vol.Schema( + { + vol.Required(CONF_USE_API_TOKEN, default=self._use_api_token): bool, + vol.Optional(CONF_API_TOKEN, default=self._api_token): str, + } + ) + if user_input: + self._api_token = user_input[CONF_API_TOKEN] + self._use_api_token = user_input[CONF_USE_API_TOKEN] + return await self._async_verify("reauth_confirm", data_schema) + return self.async_show_form(step_id="reauth_confirm", data_schema=data_schema) -class PVPCOptionsFlowHandler(config_entries.OptionsFlow): +class PVPCOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): """Handle PVPC options.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry + _power: float | None = None + _power_p3: float | None = None + + async def async_step_api_token( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle optional step to define API token for extra sensors.""" + if user_input is not None and user_input.get(CONF_API_TOKEN): + return self.async_create_entry( + title="", + data={ + ATTR_POWER: self._power, + ATTR_POWER_P3: self._power_p3, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + }, + ) + + # Fill options with entry data + api_token = self.options.get( + CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN) + ) + return self.async_show_form( + step_id="api_token", + data_schema=vol.Schema( + {vol.Required(CONF_API_TOKEN, default=api_token): str} + ), + description_placeholders={"mail_to_link": _MAIL_TO_LINK}, + ) async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + if user_input[CONF_USE_API_TOKEN]: + self._power = user_input[ATTR_POWER] + self._power_p3 = user_input[ATTR_POWER_P3] + return await self.async_step_api_token(user_input) + return self.async_create_entry( + title="", + data={ + ATTR_POWER: user_input[ATTR_POWER], + ATTR_POWER_P3: user_input[ATTR_POWER_P3], + CONF_API_TOKEN: None, + }, + ) # Fill options with entry data - power = self.config_entry.options.get( - ATTR_POWER, self.config_entry.data[ATTR_POWER] - ) - power_valley = self.config_entry.options.get( + power = self.options.get(ATTR_POWER, self.config_entry.data[ATTR_POWER]) + power_valley = self.options.get( ATTR_POWER_P3, self.config_entry.data[ATTR_POWER_P3] ) + api_token = self.options.get( + CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN) + ) + use_api_token = api_token is not None schema = vol.Schema( { vol.Required(ATTR_POWER, default=power): VALID_POWER, vol.Required(ATTR_POWER_P3, default=power_valley): VALID_POWER, + vol.Required(CONF_USE_API_TOKEN, default=use_api_token): bool, } ) return self.async_show_form(step_id="init", data_schema=schema) diff --git a/homeassistant/components/pvpc_hourly_pricing/const.py b/homeassistant/components/pvpc_hourly_pricing/const.py index 186ee1171f3..ea4d97620ec 100644 --- a/homeassistant/components/pvpc_hourly_pricing/const.py +++ b/homeassistant/components/pvpc_hourly_pricing/const.py @@ -1,9 +1,15 @@ """Constant values for pvpc_hourly_pricing.""" -from homeassistant.const import Platform +from aiopvpc import TARIFFS +import voluptuous as vol DOMAIN = "pvpc_hourly_pricing" -PLATFORMS = [Platform.SENSOR] + ATTR_POWER = "power" ATTR_POWER_P3 = "power_p3" ATTR_TARIFF = "tariff" DEFAULT_NAME = "PVPC" +CONF_USE_API_TOKEN = "use_api_token" + +VALID_POWER = vol.All(vol.Coerce(float), vol.Range(min=1.0, max=15.0)) +VALID_TARIFF = vol.In(TARIFFS) +DEFAULT_TARIFF = TARIFFS[0] diff --git a/homeassistant/components/pvpc_hourly_pricing/strings.json b/homeassistant/components/pvpc_hourly_pricing/strings.json index 1a0055ddbac..4236709fdfa 100644 --- a/homeassistant/components/pvpc_hourly_pricing/strings.json +++ b/homeassistant/components/pvpc_hourly_pricing/strings.json @@ -6,12 +6,31 @@ "name": "Sensor Name", "tariff": "Applicable tariff by geographic zone", "power": "Contracted power (kW)", - "power_p3": "Contracted power for valley period P3 (kW)" + "power_p3": "Contracted power for valley period P3 (kW)", + "use_api_token": "Enable ESIOS Personal API token for private access" + } + }, + "api_token": { + "title": "ESIOS API token", + "description": "To use the extended API you must request a personal token by mailing to {mail_to_link}.", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + } + }, + "reauth_confirm": { + "data": { + "description": "Re-authenticate with a valid token or disable it", + "use_api_token": "[%key:component::pvpc_hourly_pricing::config::step::user::data::use_api_token%]", + "api_token": "[%key:common::config_flow::data::api_token%]" } } }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { @@ -19,7 +38,15 @@ "init": { "data": { "power": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power%]", - "power_p3": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power_p3%]" + "power_p3": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power_p3%]", + "use_api_token": "[%key:component::pvpc_hourly_pricing::config::step::user::data::use_api_token%]" + } + }, + "api_token": { + "title": "[%key:component::pvpc_hourly_pricing::config::step::api_token::title%]", + "description": "[%key:component::pvpc_hourly_pricing::config::step::api_token::description%]", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" } } } diff --git a/tests/components/pvpc_hourly_pricing/conftest.py b/tests/components/pvpc_hourly_pricing/conftest.py index fb2c9188ce7..efe15547c13 100644 --- a/tests/components/pvpc_hourly_pricing/conftest.py +++ b/tests/components/pvpc_hourly_pricing/conftest.py @@ -10,6 +10,7 @@ from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_JSON_PUBLIC_DATA_2023_01_06 = "PVPC_DATA_2023_01_06.json" +FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06 = "PRICES_ESIOS_1001_2023_01_06.json" def check_valid_state(state, tariff: str, value=None, key_attr=None): @@ -21,7 +22,7 @@ def check_valid_state(state, tariff: str, value=None, key_attr=None): ) try: _ = float(state.state) - # safety margins for current electricity price (it shouldn't be out of [0, 0.2]) + # safety margins for current electricity price (it shouldn't be out of [0, 0.5]) assert -0.1 < float(state.state) < 0.5 assert state.attributes[ATTR_TARIFF] == tariff except ValueError: @@ -41,20 +42,42 @@ def pvpc_aioclient_mock(aioclient_mock: AiohttpClientMocker): mask_url_public = ( "https://api.esios.ree.es/archives/70/download_json?locale=es&date={0}" ) - # new format for prices >= 2021-06-01 + mask_url_esios = ( + "https://api.esios.ree.es/indicators/1001" + "?start_date={0}T00:00&end_date={0}T23:59" + ) example_day = "2023-01-06" aioclient_mock.get( mask_url_public.format(example_day), text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_PUBLIC_DATA_2023_01_06}"), ) + aioclient_mock.get( + mask_url_esios.format(example_day), + text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06}"), + ) + # simulate missing days aioclient_mock.get( mask_url_public.format("2023-01-07"), - status=HTTPStatus.BAD_GATEWAY, + status=HTTPStatus.OK, + text='{"message":"No values for specified archive"}', + ) + aioclient_mock.get( + mask_url_esios.format("2023-01-07"), + status=HTTPStatus.OK, text=( - '{"errors":[{"code":502,"status":"502","title":"Bad response from ESIOS",' - '"detail":"There are no data for the selected filters."}]}' + '{"indicator":{"name":"Término de facturación de energía activa del ' + 'PVPC 2.0TD","short_name":"PVPC T. 2.0TD","id":1001,"composited":false,' + '"step_type":"linear","disaggregated":true,"magnitud":' + '[{"name":"Precio","id":23}],"tiempo":[{"name":"Hora","id":4}],"geos":[],' + '"values_updated_at":null,"values":[]}}' ), ) + # simulate bad authentication + aioclient_mock.get( + mask_url_esios.format("2023-01-08"), + status=HTTPStatus.UNAUTHORIZED, + text="HTTP Token: Access denied.", + ) return aioclient_mock diff --git a/tests/components/pvpc_hourly_pricing/fixtures/PRICES_ESIOS_1001_2023_01_06.json b/tests/components/pvpc_hourly_pricing/fixtures/PRICES_ESIOS_1001_2023_01_06.json new file mode 100644 index 00000000000..20ad8af3696 --- /dev/null +++ b/tests/components/pvpc_hourly_pricing/fixtures/PRICES_ESIOS_1001_2023_01_06.json @@ -0,0 +1,1007 @@ +{ + "indicator": { + "name": "Término de facturación de energía activa del PVPC 2.0TD", + "short_name": "PVPC T. 2.0TD", + "id": 1001, + "composited": false, + "step_type": "linear", + "disaggregated": true, + "magnitud": [ + { + "name": "Precio", + "id": 23 + } + ], + "tiempo": [ + { + "name": "Hora", + "id": 4 + } + ], + "geos": [ + { + "geo_id": 8741, + "geo_name": "Península" + }, + { + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "geo_id": 8745, + "geo_name": "Melilla" + } + ], + "values_updated_at": "2023-01-05T20:17:31.000+01:00", + "values": [ + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + } + ] + } +} diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 6560c81ebbb..950aea8e32c 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -4,14 +4,15 @@ from datetime import datetime, timedelta from freezegun.api import FrozenDateTimeFactory from homeassistant import config_entries, data_entry_flow -from homeassistant.components.pvpc_hourly_pricing import ( +from homeassistant.components.pvpc_hourly_pricing.const import ( ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, + CONF_USE_API_TOKEN, DOMAIN, TARIFFS, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_API_TOKEN, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -22,6 +23,7 @@ from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker _MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=dt_util.UTC) +_MOCK_TIME_BAD_AUTH_RESPONSES = datetime(2023, 1, 8, 12, 0, tzinfo=dt_util.UTC) async def test_config_flow( @@ -35,7 +37,7 @@ async def test_config_flow( - Check state and attributes - Check abort when trying to config another with same tariff - Check removal and add again to check state restoration - - Configure options to change power and tariff to "2.0TD" + - Configure options to introduce API Token, with bad auth and good one """ freezer.move_to(_MOCK_TIME_VALID_RESPONSES) hass.config.set_time_zone("Europe/Madrid") @@ -44,6 +46,7 @@ async def test_config_flow( ATTR_TARIFF: TARIFFS[1], ATTR_POWER: 4.6, ATTR_POWER_P3: 5.75, + CONF_USE_API_TOKEN: False, } result = await hass.config_entries.flow.async_init( @@ -107,8 +110,17 @@ async def test_config_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6}, + user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: True}, ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "api_token" + assert pvpc_aioclient_mock.call_count == 2 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert pvpc_aioclient_mock.call_count == 2 await hass.async_block_till_done() state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[1]) @@ -125,3 +137,96 @@ async def test_config_flow( check_valid_state(state, tariff=TARIFFS[0], value="unavailable") assert "period" not in state.attributes assert pvpc_aioclient_mock.call_count == 4 + + # disable api token in options + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: False}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert pvpc_aioclient_mock.call_count == 4 + await hass.async_block_till_done() + + +async def test_reauth( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + pvpc_aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauth flow for API-token mode.""" + freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES) + hass.config.set_time_zone("Europe/Madrid") + tst_config = { + CONF_NAME: "test", + ATTR_TARIFF: TARIFFS[1], + ATTR_POWER: 4.6, + ATTR_POWER_P3: 5.75, + CONF_USE_API_TOKEN: True, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], tst_config + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "api_token" + assert pvpc_aioclient_mock.call_count == 0 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "api_token" + assert result["errors"]["base"] == "invalid_auth" + assert pvpc_aioclient_mock.call_count == 1 + + freezer.move_to(_MOCK_TIME_VALID_RESPONSES) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert pvpc_aioclient_mock.call_count == 3 + + # check reauth trigger with bad-auth responses + freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES) + async_fire_time_changed(hass, _MOCK_TIME_BAD_AUTH_RESPONSES) + await hass.async_block_till_done() + assert pvpc_aioclient_mock.call_count == 4 + + result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] + assert result["context"]["entry_id"] == config_entry.entry_id + assert result["context"]["source"] == config_entries.SOURCE_REAUTH + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert pvpc_aioclient_mock.call_count == 5 + + result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] + assert result["context"]["entry_id"] == config_entry.entry_id + assert result["context"]["source"] == config_entries.SOURCE_REAUTH + assert result["step_id"] == "reauth_confirm" + + freezer.move_to(_MOCK_TIME_VALID_RESPONSES) + async_fire_time_changed(hass, _MOCK_TIME_VALID_RESPONSES) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert pvpc_aioclient_mock.call_count == 6 + + await hass.async_block_till_done() + assert pvpc_aioclient_mock.call_count == 7 From a91fad47bceafb6584d124bd17d406c931c004b3 Mon Sep 17 00:00:00 2001 From: nachonam Date: Thu, 23 Nov 2023 08:57:15 +0100 Subject: [PATCH 679/982] Bump pysuez to 0.2.0 (#104338) --- homeassistant/components/suez_water/manifest.json | 2 +- homeassistant/components/suez_water/sensor.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 15c346fadab..3da91c4aa52 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], - "requirements": ["pysuez==0.1.19"] + "requirements": ["pysuez==0.2.0"] } diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 43075276be6..d0c1bba211e 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -45,7 +45,7 @@ def setup_platform( password = config[CONF_PASSWORD] counter_id = config[CONF_COUNTER_ID] try: - client = SuezClient(username, password, counter_id) + client = SuezClient(username, password, counter_id, provider=None) if not client.check_credentials(): _LOGGER.warning("Wrong username and/or password") diff --git a/requirements_all.txt b/requirements_all.txt index b090f5ff6b0..2c2e6fd88ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2089,7 +2089,7 @@ pysqueezebox==0.6.3 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuez==0.1.19 +pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 From ada0578f3a1b9fb9a89ef35788940586a24eac0b Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 23 Nov 2023 09:03:20 +0100 Subject: [PATCH 680/982] Update gridnet lib to v5.0.0 (#104396) --- homeassistant/components/pure_energie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pure_energie/manifest.json b/homeassistant/components/pure_energie/manifest.json index 4c83b5e3651..19098c41208 100644 --- a/homeassistant/components/pure_energie/manifest.json +++ b/homeassistant/components/pure_energie/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/pure_energie", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["gridnet==4.2.0"], + "requirements": ["gridnet==5.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 2c2e6fd88ef..1003b4ad740 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -945,7 +945,7 @@ greeneye_monitor==3.0.3 greenwavereality==0.5.1 # homeassistant.components.pure_energie -gridnet==4.2.0 +gridnet==5.0.0 # homeassistant.components.growatt_server growattServer==1.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e40aed8d023..3736e873c56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -747,7 +747,7 @@ greeclimate==1.4.1 greeneye_monitor==3.0.3 # homeassistant.components.pure_energie -gridnet==4.2.0 +gridnet==5.0.0 # homeassistant.components.growatt_server growattServer==1.3.0 From 2febc9c4b3429b83f2877e072f975b01edde5eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 23 Nov 2023 09:13:00 +0100 Subject: [PATCH 681/982] Force IPv4 when getting location information (#104363) --- homeassistant/components/cloudflare/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index 1901bfdc0e7..d4c6775c6b9 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -4,8 +4,8 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +import socket -from aiohttp import ClientSession import pycfdns from homeassistant.config_entries import ConfigEntry @@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up recurring update.""" try: await _async_update_cloudflare( - session, client, dns_zone, entry.data[CONF_RECORDS] + hass, client, dns_zone, entry.data[CONF_RECORDS] ) except ( pycfdns.AuthenticationException, @@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up service for manual trigger.""" try: await _async_update_cloudflare( - session, client, dns_zone, entry.data[CONF_RECORDS] + hass, client, dns_zone, entry.data[CONF_RECORDS] ) except ( pycfdns.AuthenticationException, @@ -92,7 +92,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_cloudflare( - session: ClientSession, + hass: HomeAssistant, client: pycfdns.Client, dns_zone: pycfdns.ZoneModel, target_records: list[str], @@ -102,6 +102,7 @@ async def _async_update_cloudflare( records = await client.list_dns_records(zone_id=dns_zone["id"], type="A") _LOGGER.debug("Records: %s", records) + session = async_get_clientsession(hass, family=socket.AF_INET) location_info = await async_detect_location_info(session) if not location_info or not is_ipv4_address(location_info.ip): From 1144e33e685caf45a8ad26b803ef0ba7f3c8ab4a Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 23 Nov 2023 10:23:15 +0100 Subject: [PATCH 682/982] Add re-auth config flow strings for Sure Petcare (#104357) --- homeassistant/components/surepetcare/config_flow.py | 1 + homeassistant/components/surepetcare/strings.json | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 7c4509259ad..38bed2e20a9 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -118,6 +118,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", + description_placeholders={"username": self._username}, data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), errors=errors, ) diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json index 2d297cc829e..c3b7864f36a 100644 --- a/homeassistant/components/surepetcare/strings.json +++ b/homeassistant/components/surepetcare/strings.json @@ -6,6 +6,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Re-authenticate by entering password for {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { From d460eadce02c6b4213e177efc8847e3f173a67b2 Mon Sep 17 00:00:00 2001 From: Mike Heath Date: Thu, 23 Nov 2023 02:38:32 -0700 Subject: [PATCH 683/982] Add support to fully_kiosk for hybrid local push/pull switches using MQTT (#89010) * Support hybrid local push/pull switches using MQTT * Update homeassistant/components/fully_kiosk/entity.py Co-authored-by: Erik Montnemery * Fix MQTT subscribe method --------- Co-authored-by: Erik Montnemery --- .../components/fully_kiosk/entity.py | 29 ++++++++++ .../components/fully_kiosk/manifest.json | 1 + .../components/fully_kiosk/switch.py | 56 ++++++++++++++++--- tests/components/fully_kiosk/test_switch.py | 48 +++++++++++++++- 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index 87c441dd545..5fd9f75a6a0 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -1,9 +1,13 @@ """Base entity for the Fully Kiosk Browser integration.""" from __future__ import annotations +import json + from yarl import URL +from homeassistant.components import mqtt from homeassistant.const import ATTR_CONNECTIONS +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -54,3 +58,28 @@ class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entit (CONNECTION_NETWORK_MAC, coordinator.data["Mac"]) } self._attr_device_info = device_info + + async def mqtt_subscribe( + self, event: str | None, event_callback: CALLBACK_TYPE + ) -> CALLBACK_TYPE | None: + """Subscribe to MQTT for a given event.""" + data = self.coordinator.data + if ( + event is None + or not mqtt.mqtt_config_entry_enabled(self.hass) + or not data["settings"]["mqttEnabled"] + ): + return None + + @callback + def message_callback(message: mqtt.ReceiveMessage) -> None: + payload = json.loads(message.payload) + event_callback(**payload) + + topic_template = data["settings"]["mqttEventTopic"] + topic = ( + topic_template.replace("$appId", "fully") + .replace("$event", event) + .replace("$deviceId", data["deviceID"]) + ) + return await mqtt.async_subscribe(self.hass, topic, message_callback) diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index dcd36671fce..b5dadf14184 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -1,6 +1,7 @@ { "domain": "fully_kiosk", "name": "Fully Kiosk Browser", + "after_dependencies": ["mqtt"], "codeowners": ["@cgarwood"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index 500e154abd8..c1d5d4e5c75 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -10,7 +10,7 @@ from fullykiosk import FullyKiosk from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -25,6 +25,8 @@ class FullySwitchEntityDescriptionMixin: on_action: Callable[[FullyKiosk], Any] off_action: Callable[[FullyKiosk], Any] is_on_fn: Callable[[dict[str, Any]], Any] + mqtt_on_event: str | None + mqtt_off_event: str | None @dataclass @@ -41,6 +43,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.startScreensaver(), off_action=lambda fully: fully.stopScreensaver(), is_on_fn=lambda data: data.get("isInScreensaver"), + mqtt_on_event="onScreensaverStart", + mqtt_off_event="onScreensaverStop", ), FullySwitchEntityDescription( key="maintenance", @@ -49,6 +53,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.enableLockedMode(), off_action=lambda fully: fully.disableLockedMode(), is_on_fn=lambda data: data.get("maintenanceMode"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="kiosk", @@ -57,6 +63,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.lockKiosk(), off_action=lambda fully: fully.unlockKiosk(), is_on_fn=lambda data: data.get("kioskLocked"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="motion-detection", @@ -65,6 +73,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.enableMotionDetection(), off_action=lambda fully: fully.disableMotionDetection(), is_on_fn=lambda data: data["settings"].get("motionDetection"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="screenOn", @@ -72,6 +82,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.screenOn(), off_action=lambda fully: fully.screenOff(), is_on_fn=lambda data: data.get("screenOn"), + mqtt_on_event="screenOn", + mqtt_off_event="screenOff", ), ) @@ -105,13 +117,27 @@ class FullySwitchEntity(FullyKioskEntity, SwitchEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" + self._turned_on_subscription: CALLBACK_TYPE | None = None + self._turned_off_subscription: CALLBACK_TYPE | None = None - @property - def is_on(self) -> bool | None: - """Return true if the entity is on.""" - if (is_on := self.entity_description.is_on_fn(self.coordinator.data)) is None: - return None - return bool(is_on) + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + description = self.entity_description + self._turned_on_subscription = await self.mqtt_subscribe( + description.mqtt_off_event, self._turn_off + ) + self._turned_off_subscription = await self.mqtt_subscribe( + description.mqtt_on_event, self._turn_on + ) + + async def async_will_remove_from_hass(self) -> None: + """Close MQTT subscriptions when removed.""" + await super().async_will_remove_from_hass() + if self._turned_off_subscription is not None: + self._turned_off_subscription() + if self._turned_on_subscription is not None: + self._turned_on_subscription() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -122,3 +148,19 @@ class FullySwitchEntity(FullyKioskEntity, SwitchEntity): """Turn the entity off.""" await self.entity_description.off_action(self.coordinator.fully) await self.coordinator.async_refresh() + + def _turn_off(self, **kwargs: Any) -> None: + """Optimistically turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + + def _turn_on(self, **kwargs: Any) -> None: + """Optimistically turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = bool(self.entity_description.is_on_fn(self.coordinator.data)) + self.async_write_ha_state() diff --git a/tests/components/fully_kiosk/test_switch.py b/tests/components/fully_kiosk/test_switch.py index 4cbdad8d63a..20b5ed11998 100644 --- a/tests/components/fully_kiosk/test_switch.py +++ b/tests/components/fully_kiosk/test_switch.py @@ -7,7 +7,8 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient async def test_switches( @@ -86,6 +87,51 @@ async def test_switches( assert device_entry.sw_version == "1.42.5" +async def test_switches_mqtt_update( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + mqtt_mock: MqttMockHAClient, + init_integration: MockConfigEntry, +) -> None: + """Test push updates over MQTT.""" + assert has_subscribed(mqtt_mock, "fully/event/onScreensaverStart/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/onScreensaverStop/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/screenOff/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/screenOn/abcdef-123456") + + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity + assert entity.state == "off" + + entity = hass.states.get("switch.amazon_fire_screen") + assert entity + assert entity.state == "on" + + async_fire_mqtt_message(hass, "fully/event/onScreensaverStart/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity.state == "on" + + async_fire_mqtt_message(hass, "fully/event/onScreensaverStop/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity.state == "off" + + async_fire_mqtt_message(hass, "fully/event/screenOff/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screen") + assert entity.state == "off" + + async_fire_mqtt_message(hass, "fully/event/screenOn/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screen") + assert entity.state == "on" + + +def has_subscribed(mqtt_mock: MqttMockHAClient, topic: str) -> bool: + """Check if MQTT topic has subscription.""" + for call in mqtt_mock.async_subscribe.call_args_list: + if call.args[0] == topic: + return True + return False + + def call_service(hass, service, entity_id): """Call any service on entity.""" return hass.services.async_call( From 933cd89833f9baef27b464458f91294e96821dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Huryn?= Date: Thu, 23 Nov 2023 11:15:24 +0100 Subject: [PATCH 684/982] Blebox update IP address if already configured via zeroconf (#90511) feat: zeroconf, update ip address if device allready configured --- homeassistant/components/blebox/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 31d1f6162d7..977e704eb98 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -112,7 +112,7 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.device_config["name"] = product.name # Check if configured but IP changed since await self.async_set_unique_id(product.unique_id) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) self.context.update( { "title_placeholders": { From e9920ff73d3f3a218a795e056ef91031ff663e53 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 23 Nov 2023 06:51:29 -0500 Subject: [PATCH 685/982] Add select entity for zwave_js Door Lock CC (#104292) * Add select entity for zwave_js Door Lock CC * fix test --- .../components/zwave_js/discovery.py | 9 +++++- homeassistant/components/zwave_js/select.py | 28 +++++++++++++++++-- tests/components/zwave_js/test_discovery.py | 1 + tests/components/zwave_js/test_init.py | 4 +-- tests/components/zwave_js/test_select.py | 27 ++++++++++++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 39d8c0e8855..ab1d0660cca 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -664,7 +664,14 @@ DISCOVERY_SCHEMAS = [ # locks # Door Lock CC ZWaveDiscoverySchema( - platform=Platform.LOCK, primary_value=DOOR_LOCK_CURRENT_MODE_SCHEMA + platform=Platform.LOCK, + primary_value=DOOR_LOCK_CURRENT_MODE_SCHEMA, + allow_multi=True, + ), + ZWaveDiscoverySchema( + platform=Platform.SELECT, + primary_value=DOOR_LOCK_CURRENT_MODE_SCHEMA, + hint="door_lock", ), # Only discover the Lock CC if the Door Lock CC isn't also present on the node ZWaveDiscoverySchema( diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 3956004336a..e838949d3e1 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -5,7 +5,8 @@ from typing import cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass -from zwave_js_server.const.command_class.sound_switch import ToneID +from zwave_js_server.const.command_class.lock import TARGET_MODE_PROPERTY +from zwave_js_server.const.command_class.sound_switch import TONE_ID_PROPERTY, ToneID from zwave_js_server.model.driver import Driver from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity @@ -46,6 +47,8 @@ async def async_setup_entry( entities.append( ZWaveConfigParameterSelectEntity(config_entry, driver, info) ) + elif info.platform_hint == "door_lock": + entities.append(ZWaveDoorLockSelectEntity(config_entry, driver, info)) else: entities.append(ZwaveSelectEntity(config_entry, driver, info)) async_add_entities(entities) @@ -95,6 +98,27 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): await self._async_set_value(self.info.primary_value, int(key)) +class ZWaveDoorLockSelectEntity(ZwaveSelectEntity): + """Representation of a Z-Wave door lock CC mode select entity.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveDoorLockSelectEntity entity.""" + super().__init__(config_entry, driver, info) + self._target_value = self.get_zwave_value(TARGET_MODE_PROPERTY) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + assert self._target_value is not None + key = next( + key + for key, val in self.info.primary_value.metadata.states.items() + if val == option + ) + await self._async_set_value(self._target_value, int(key)) + + class ZWaveConfigParameterSelectEntity(ZwaveSelectEntity): """Representation of a Z-Wave config parameter select.""" @@ -125,7 +149,7 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): """Initialize a ZwaveDefaultToneSelectEntity entity.""" super().__init__(config_entry, driver, info) self._tones_value = self.get_zwave_value( - "toneId", command_class=CommandClass.SOUND_SWITCH + TONE_ID_PROPERTY, command_class=CommandClass.SOUND_SWITCH ) # Entity class attributes diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index cbaa27c2a91..569e36d3b5c 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -87,6 +87,7 @@ async def test_lock_popp_electric_strike_lock_control( hass.states.get("binary_sensor.node_62_the_current_status_of_the_door") is not None ) + assert hass.states.get("select.node_62_current_lock_mode") is not None async def test_fortrez_ssa3_siren( diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index c57e3b1f868..bf015a70676 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -967,7 +967,7 @@ async def test_removed_device( # Check how many entities there are ent_reg = er.async_get(hass) entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 92 + assert len(entity_entries) == 93 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) @@ -979,7 +979,7 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 2 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 61 + assert len(entity_entries) == 62 assert ( dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None ) diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index c63f0c429fd..1cbdb8799f3 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -320,3 +320,30 @@ async def test_config_parameter_select( state = hass.states.get(select_entity_id) assert state assert state.state == "Normal" + + +async def test_lock_popp_electric_strike_lock_control_select( + hass: HomeAssistant, client, lock_popp_electric_strike_lock_control, integration +) -> None: + """Test that the Popp Electric Strike Lock Control select entity.""" + LOCK_SELECT_ENTITY = "select.node_62_current_lock_mode" + state = hass.states.get(LOCK_SELECT_ENTITY) + assert state + assert state.state == "Unsecured" + await hass.services.async_call( + "select", + "select_option", + {"entity_id": LOCK_SELECT_ENTITY, "option": "UnsecuredWithTimeout"}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == lock_popp_electric_strike_lock_control.node_id + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 98, + "property": "targetMode", + } + assert args["value"] == 1 From 6b138a276a0842aa5fa5bd866c671c7d18876e4a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 23 Nov 2023 12:55:21 +0100 Subject: [PATCH 686/982] Add diagnostics platform to Reolink (#104378) --- .../components/reolink/diagnostics.py | 46 +++++++++++++++++ homeassistant/components/reolink/host.py | 9 ++++ tests/components/reolink/conftest.py | 12 +++++ .../reolink/snapshots/test_diagnostics.ambr | 50 +++++++++++++++++++ tests/components/reolink/test_diagnostics.py | 25 ++++++++++ 5 files changed, 142 insertions(+) create mode 100644 homeassistant/components/reolink/diagnostics.py create mode 100644 tests/components/reolink/snapshots/test_diagnostics.ambr create mode 100644 tests/components/reolink/test_diagnostics.py diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py new file mode 100644 index 00000000000..04b476296f8 --- /dev/null +++ b/homeassistant/components/reolink/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnostics support for Reolink.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import ReolinkData +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + host = reolink_data.host + api = host.api + + IPC_cam: dict[int, dict[str, Any]] = {} + for ch in api.channels: + IPC_cam[ch] = {} + IPC_cam[ch]["model"] = api.camera_model(ch) + IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) + + return { + "model": api.model, + "hardware version": api.hardware_version, + "firmware version": api.sw_version, + "HTTPS": api.use_https, + "HTTP(S) port": api.port, + "WiFi connection": api.wifi_connection, + "WiFi signal": api.wifi_signal, + "RTMP enabled": api.rtmp_enabled, + "RTSP enabled": api.rtsp_enabled, + "ONVIF enabled": api.onvif_enabled, + "event connection": host.event_connection, + "stream protocol": api.protocol, + "channels": api.channels, + "stream channels": api.stream_channels, + "IPC cams": IPC_cam, + "capabilities": api.capabilities, + "api versions": api.checked_api_versions, + "abilities": api.abilities, + } diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 0075bbac4e6..f6eb4cb0e55 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -661,3 +661,12 @@ class ReolinkHost: for channel in channels: async_dispatcher_send(self._hass, f"{self.webhook_id}_{channel}", {}) + + @property + def event_connection(self) -> str: + """Type of connection to receive events.""" + if self._webhook_reachable: + return "ONVIF push" + if self._long_poll_received: + return "ONVIF long polling" + return "Fast polling" diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index e8980b615e2..75d2dc0c661 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -63,6 +63,8 @@ def reolink_connect_class( host_mock.use_https = TEST_USE_HTTPS host_mock.is_admin = True host_mock.user_level = "admin" + host_mock.protocol = "rtsp" + host_mock.channels = [0] host_mock.stream_channels = [0] host_mock.sw_version_update_required = False host_mock.hardware_version = "IPC_00000" @@ -75,6 +77,16 @@ def reolink_connect_class( host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 + host_mock.wifi_connection = False + host_mock.wifi_signal = None + host_mock.whiteled_mode_list.return_value = [] + host_mock.zoom_range.return_value = { + "zoom": {"pos": {"min": 0, "max": 100}}, + "focus": {"pos": {"min": 0, "max": 100}}, + } + host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} + host_mock.checked_api_versions = {"GetEvents": 1} + host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} yield host_mock_class diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..604a9364320 --- /dev/null +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'HTTP(S) port': 1234, + 'HTTPS': True, + 'IPC cams': dict({ + '0': dict({ + 'firmware version': 'v1.1.0.0.0.0000', + 'model': 'RLC-123', + }), + }), + 'ONVIF enabled': True, + 'RTMP enabled': True, + 'RTSP enabled': True, + 'WiFi connection': False, + 'WiFi signal': None, + 'abilities': dict({ + 'abilityChn': list([ + dict({ + 'aiTrack': dict({ + 'permit': 0, + 'ver': 0, + }), + }), + ]), + }), + 'api versions': dict({ + 'GetEvents': 1, + }), + 'capabilities': dict({ + '0': list([ + 'motion_detection', + ]), + 'Host': list([ + 'RTSP', + ]), + }), + 'channels': list([ + 0, + ]), + 'event connection': 'Fast polling', + 'firmware version': 'v1.0.0.0.0.0000', + 'hardware version': 'IPC_00000', + 'model': 'RLC-123', + 'stream channels': list([ + 0, + ]), + 'stream protocol': 'rtsp', + }) +# --- diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py new file mode 100644 index 00000000000..57b474c13ad --- /dev/null +++ b/tests/components/reolink/test_diagnostics.py @@ -0,0 +1,25 @@ +"""Test Reolink diagnostics.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test Reolink diagnostics.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == snapshot From a1f7f899c94f3b02baeaeb34bae1c7315338d20c Mon Sep 17 00:00:00 2001 From: aptalca <541623+aptalca@users.noreply.github.com> Date: Thu, 23 Nov 2023 07:51:51 -0500 Subject: [PATCH 687/982] Make SMTP notify send images as attachments if html is disabled (#93562) smtp notify: send images without html as attachments update smtp test to detect content_type for plain txt + image --- homeassistant/components/smtp/notify.py | 47 ++++++++++++++----------- tests/components/smtp/test_notify.py | 2 +- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6836a0b9f6b..6b960409305 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -185,9 +185,8 @@ class MailNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Build and send a message to a user. - Will send plain text normally, or will build a multipart HTML message - with inline image attachments if images config is defined, or will - build a multipart HTML if html config is defined. + Will send plain text normally, with pictures as attachments if images config is + defined, or will build a multipart HTML if html config is defined. """ subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) @@ -242,8 +241,12 @@ def _build_text_msg(message): return MIMEText(message) -def _attach_file(atch_name, content_id): - """Create a message attachment.""" +def _attach_file(atch_name, content_id=""): + """Create a message attachment. + + If MIMEImage is successful and content_id is passed (HTML), add images in-line. + Otherwise add them as attachments. + """ try: with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() @@ -258,32 +261,34 @@ def _attach_file(atch_name, content_id): "Attachment %s has an unknown MIME type. Falling back to file", atch_name, ) - attachment = MIMEApplication(file_bytes, Name=atch_name) - attachment["Content-Disposition"] = f'attachment; filename="{atch_name}"' + attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name)) + attachment[ + "Content-Disposition" + ] = f'attachment; filename="{os.path.basename(atch_name)}"' + else: + if content_id: + attachment.add_header("Content-ID", f"<{content_id}>") + else: + attachment.add_header( + "Content-Disposition", + f"attachment; filename={os.path.basename(atch_name)}", + ) - attachment.add_header("Content-ID", f"<{content_id}>") return attachment def _build_multipart_msg(message, images): - """Build Multipart message with in-line images.""" - _LOGGER.debug("Building multipart email with embedded attachment(s)") - msg = MIMEMultipart("related") - msg_alt = MIMEMultipart("alternative") - msg.attach(msg_alt) + """Build Multipart message with images as attachments.""" + _LOGGER.debug("Building multipart email with image attachment(s)") + msg = MIMEMultipart() body_txt = MIMEText(message) - msg_alt.attach(body_txt) - body_text = [f"

{message}


"] + msg.attach(body_txt) - for atch_num, atch_name in enumerate(images): - cid = f"image{atch_num}" - body_text.append(f'
') - attachment = _attach_file(atch_name, cid) + for atch_name in images: + attachment = _attach_file(atch_name) if attachment: msg.attach(attachment) - body_html = MIMEText("".join(body_text), "html") - msg_alt.attach(body_html) return msg diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 86a21c754ed..bca5a5674df 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -101,7 +101,7 @@ EMAIL_DATA = [ ( "Test msg", {"images": ["tests/testing_config/notify/test.jpg"]}, - "Content-Type: multipart/related", + "Content-Type: multipart/mixed", ), ( "Test msg", From 616f6aab7640640c4ade338dfd08293288df3da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 23 Nov 2023 20:35:35 +0200 Subject: [PATCH 688/982] Add Huawei LTE restart and clear traffic statistics buttons (#91967) * Add Huawei LTE restart and clear traffic statistics buttons Deprecate corresponding services in favour of these. * Change to be removed service warnings to issues * Add tests * Update planned service remove versions --- .../components/huawei_lte/__init__.py | 30 ++++++ homeassistant/components/huawei_lte/button.py | 97 +++++++++++++++++++ homeassistant/components/huawei_lte/const.py | 3 + .../components/huawei_lte/strings.json | 6 ++ tests/components/huawei_lte/__init__.py | 22 +++++ tests/components/huawei_lte/test_button.py | 76 +++++++++++++++ tests/components/huawei_lte/test_switches.py | 22 +---- 7 files changed, 236 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/huawei_lte/button.py create mode 100644 tests/components/huawei_lte/test_button.py diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 929ca0193af..d0d1ce71161 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -50,6 +50,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -57,6 +58,8 @@ from .const import ( ADMIN_SERVICES, ALL_KEYS, ATTR_CONFIG_ENTRY_ID, + BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, + BUTTON_KEY_RESTART, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, CONNECTION_TIMEOUT, @@ -127,6 +130,7 @@ SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url}) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, @@ -524,12 +528,38 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return if service.service == SERVICE_CLEAR_TRAFFIC_STATISTICS: + create_issue( + hass, + DOMAIN, + "service_clear_traffic_statistics_moved_to_button", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="service_changed_to_button", + translation_placeholders={ + "service": service.service, + "button": BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, + }, + ) if router.suspended: _LOGGER.debug("%s: ignored, integration suspended", service.service) return result = router.client.monitoring.set_clear_traffic() _LOGGER.debug("%s: %s", service.service, result) elif service.service == SERVICE_REBOOT: + create_issue( + hass, + DOMAIN, + "service_reboot_moved_to_button", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="service_changed_to_button", + translation_placeholders={ + "service": service.service, + "button": BUTTON_KEY_RESTART, + }, + ) if router.suspended: _LOGGER.debug("%s: ignored, integration suspended", service.service) return diff --git a/homeassistant/components/huawei_lte/button.py b/homeassistant/components/huawei_lte/button.py new file mode 100644 index 00000000000..f494836e80d --- /dev/null +++ b/homeassistant/components/huawei_lte/button.py @@ -0,0 +1,97 @@ +"""Huawei LTE buttons.""" + +from __future__ import annotations + +import logging + +from huawei_lte_api.enums.device import ControlModeEnum + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_platform + +from . import HuaweiLteBaseEntityWithDevice +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up Huawei LTE buttons.""" + router = hass.data[DOMAIN].routers[config_entry.entry_id] + buttons = [ + ClearTrafficStatisticsButton(router), + RestartButton(router), + ] + async_add_entities(buttons) + + +class BaseButton(HuaweiLteBaseEntityWithDevice, ButtonEntity): + """Huawei LTE button base class.""" + + @property + def _device_unique_id(self) -> str: + """Return unique ID for entity within a router.""" + return f"button-{self.entity_description.key}" + + async def async_update(self) -> None: + """Update is not necessary for button entities.""" + + def press(self) -> None: + """Press button.""" + if self.router.suspended: + _LOGGER.debug( + "%s: ignored, integration suspended", self.entity_description.key + ) + return + result = self._press() + _LOGGER.debug("%s: %s", self.entity_description.key, result) + + def _press(self) -> str: + """Invoke low level action of button press.""" + raise NotImplementedError + + +BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" + + +class ClearTrafficStatisticsButton(BaseButton): + """Huawei LTE clear traffic statistics button.""" + + entity_description = ButtonEntityDescription( + key=BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, + name="Clear traffic statistics", + entity_category=EntityCategory.CONFIG, + ) + + def _press(self) -> str: + """Call clear traffic statistics endpoint.""" + return self.router.client.monitoring.set_clear_traffic() + + +BUTTON_KEY_RESTART = "restart" + + +class RestartButton(BaseButton): + """Huawei LTE restart button.""" + + entity_description = ButtonEntityDescription( + key=BUTTON_KEY_RESTART, + name="Restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + ) + + def _press(self) -> str: + """Call restart endpoint.""" + return self.router.client.device.set_control(ControlModeEnum.REBOOT) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 53cc0efb919..eba0f3ce90b 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -79,3 +79,6 @@ ALL_KEYS = ( | SWITCH_KEYS | {KEY_DEVICE_BASIC_INFORMATION} ) + +BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" +BUTTON_KEY_RESTART = "restart" diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index f188eb9e17b..1e43aa818e9 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -279,6 +279,12 @@ } } }, + "issues": { + "service_changed_to_button": { + "title": "Service changed to a button", + "description": "The {service} service is deprecated, use the corresponding {button} button instead." + } + }, "services": { "clear_traffic_statistics": { "name": "Clear traffic statistics", diff --git a/tests/components/huawei_lte/__init__.py b/tests/components/huawei_lte/__init__.py index 79602ecfb44..2d43a5eade1 100644 --- a/tests/components/huawei_lte/__init__.py +++ b/tests/components/huawei_lte/__init__.py @@ -1 +1,23 @@ """Tests for the huawei_lte component.""" + +from unittest.mock import MagicMock + +from huawei_lte_api.enums.cradle import ConnectionStatusEnum + + +def magic_client(multi_basic_settings_value: dict) -> MagicMock: + """Mock huawei_lte.Client.""" + information = MagicMock(return_value={"SerialNumber": "test-serial-number"}) + check_notifications = MagicMock(return_value={"SmsStorageFull": 0}) + status = MagicMock( + return_value={"ConnectionStatus": ConnectionStatusEnum.CONNECTED.value} + ) + multi_basic_settings = MagicMock(return_value=multi_basic_settings_value) + wifi_feature_switch = MagicMock(return_value={"wifi24g_switch_enable": 1}) + device = MagicMock(information=information) + monitoring = MagicMock(check_notifications=check_notifications, status=status) + wlan = MagicMock( + multi_basic_settings=multi_basic_settings, + wifi_feature_switch=wifi_feature_switch, + ) + return MagicMock(device=device, monitoring=monitoring, wlan=wlan) diff --git a/tests/components/huawei_lte/test_button.py b/tests/components/huawei_lte/test_button.py new file mode 100644 index 00000000000..982fba166c3 --- /dev/null +++ b/tests/components/huawei_lte/test_button.py @@ -0,0 +1,76 @@ +"""Tests for the Huawei LTE switches.""" +from unittest.mock import MagicMock, patch + +from huawei_lte_api.enums.device import ControlModeEnum + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.huawei_lte.const import ( + BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, + BUTTON_KEY_RESTART, + DOMAIN, + SERVICE_SUSPEND_INTEGRATION, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client + +from tests.common import MockConfigEntry + +MOCK_CONF_URL = "http://huawei-lte.example.com" + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client", return_value=magic_client({})) +async def test_clear_traffic_statistics(client, hass: HomeAssistant) -> None: + """Test clear traffic statistics button.""" + huawei_lte = MockConfigEntry(domain=DOMAIN, data={CONF_URL: MOCK_CONF_URL}) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.lte_{BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS}"}, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.monitoring.set_clear_traffic.assert_called_once() + + client.return_value.monitoring.set_clear_traffic.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_SUSPEND_INTEGRATION, + {CONF_URL: MOCK_CONF_URL}, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.monitoring.set_clear_traffic.assert_not_called() + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client", return_value=magic_client({})) +async def test_restart(client, hass: HomeAssistant) -> None: + """Test restart button.""" + huawei_lte = MockConfigEntry(domain=DOMAIN, data={CONF_URL: MOCK_CONF_URL}) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.lte_{BUTTON_KEY_RESTART}"}, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.device.set_control.assert_called_with(ControlModeEnum.REBOOT) + + client.return_value.device.set_control.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_SUSPEND_INTEGRATION, + {CONF_URL: MOCK_CONF_URL}, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.device.set_control.assert_not_called() diff --git a/tests/components/huawei_lte/test_switches.py b/tests/components/huawei_lte/test_switches.py index e686c2356e6..acaffdbd0ba 100644 --- a/tests/components/huawei_lte/test_switches.py +++ b/tests/components/huawei_lte/test_switches.py @@ -1,8 +1,6 @@ """Tests for the Huawei LTE switches.""" from unittest.mock import MagicMock, patch -from huawei_lte_api.enums.cradle import ConnectionStatusEnum - from homeassistant.components.huawei_lte.const import DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -13,29 +11,13 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_URL, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import magic_client + from tests.common import MockConfigEntry SWITCH_WIFI_GUEST_NETWORK = "switch.lte_wi_fi_guest_network" -def magic_client(multi_basic_settings_value: dict) -> MagicMock: - """Mock huawei_lte.Client.""" - information = MagicMock(return_value={"SerialNumber": "test-serial-number"}) - check_notifications = MagicMock(return_value={"SmsStorageFull": 0}) - status = MagicMock( - return_value={"ConnectionStatus": ConnectionStatusEnum.CONNECTED.value} - ) - multi_basic_settings = MagicMock(return_value=multi_basic_settings_value) - wifi_feature_switch = MagicMock(return_value={"wifi24g_switch_enable": 1}) - device = MagicMock(information=information) - monitoring = MagicMock(check_notifications=check_notifications, status=status) - wlan = MagicMock( - multi_basic_settings=multi_basic_settings, - wifi_feature_switch=wifi_feature_switch, - ) - return MagicMock(device=device, monitoring=monitoring, wlan=wlan) - - @patch("homeassistant.components.huawei_lte.Connection", MagicMock()) @patch("homeassistant.components.huawei_lte.Client", return_value=magic_client({})) async def test_huawei_lte_wifi_guest_network_config_entry_when_network_is_not_present( From d78c0bd948b4ea7dfdef7e039dad7272a2b5c680 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 23 Nov 2023 20:25:26 +0100 Subject: [PATCH 689/982] Handle 403 errors in UniFi (#104387) UniFi handle 403 error --- homeassistant/components/unifi/controller.py | 8 ++++++++ tests/components/unifi/test_controller.py | 1 + 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index b89e64f285f..6bd8b9db426 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -506,6 +506,14 @@ async def get_unifi_controller( ) raise CannotConnect from err + except aiounifi.Forbidden as err: + LOGGER.warning( + "Access forbidden to UniFi Network at %s, check access rights: %s", + config[CONF_HOST], + err, + ) + raise AuthenticationRequired from err + except aiounifi.LoginRequired as err: LOGGER.warning( "Connected to UniFi Network at %s but login required: %s", diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index d96b5d36d22..268f4e8493a 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -465,6 +465,7 @@ async def test_get_unifi_controller_verify_ssl_false(hass: HomeAssistant) -> Non (aiounifi.RequestError, CannotConnect), (aiounifi.ResponseError, CannotConnect), (aiounifi.Unauthorized, AuthenticationRequired), + (aiounifi.Forbidden, AuthenticationRequired), (aiounifi.LoginRequired, AuthenticationRequired), (aiounifi.AiounifiException, AuthenticationRequired), ], From b24fa4839db8a1e10d34d544ae6845838dc9c5f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Nov 2023 22:59:51 +0100 Subject: [PATCH 690/982] Bump aioesphomeapi to 18.5.7 (#104426) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 89eb6629cf9..a8d8305a7b5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.5.6", + "aioesphomeapi==18.5.7", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1003b4ad740..9fc6c58702f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.6 +aioesphomeapi==18.5.7 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3736e873c56..1fdc32cd99a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.6 +aioesphomeapi==18.5.7 # homeassistant.components.flo aioflo==2021.11.0 From 258a93bf199c2c4b6c8d86d3194f5b060f8b7a40 Mon Sep 17 00:00:00 2001 From: Damian Sypniewski <16312757+dsypniewski@users.noreply.github.com> Date: Fri, 24 Nov 2023 16:39:56 +0900 Subject: [PATCH 691/982] Bump yeelight to 0.7.14 (#104439) --- homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 6c44736fa6d..b3bc0c30bf4 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.36.2"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.36.2"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 9fc6c58702f..ccb3d8dfb8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2787,7 +2787,7 @@ yalexs-ble==2.3.2 yalexs==1.10.0 # homeassistant.components.yeelight -yeelight==0.7.13 +yeelight==0.7.14 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fdc32cd99a..c9cd8129e6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2085,7 +2085,7 @@ yalexs-ble==2.3.2 yalexs==1.10.0 # homeassistant.components.yeelight -yeelight==0.7.13 +yeelight==0.7.14 # homeassistant.components.yolink yolink-api==0.3.1 From 6a2fd434fcea91d140eb3212b816fda3620aa068 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 24 Nov 2023 04:07:16 -0500 Subject: [PATCH 692/982] Bump Python Roborock to 0.36.2 (#104441) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 3b741995cd4..beb467d69f9 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==0.36.1", + "python-roborock==0.36.2", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/requirements_all.txt b/requirements_all.txt index ccb3d8dfb8c..f9b8c6d4852 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2198,7 +2198,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.36.1 +python-roborock==0.36.2 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9cd8129e6d..9bcff0b3e61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1640,7 +1640,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.36.1 +python-roborock==0.36.2 # homeassistant.components.smarttub python-smarttub==0.0.36 From 140b5633120f8cdf5403d61c32c352531ea4079c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 24 Nov 2023 10:08:48 +0100 Subject: [PATCH 693/982] Update mypy to 1.7.1 (#104434) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index bc88a59fc8e..d880fecaca5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==3.0.1 coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.7.0 +mypy==1.7.1 pre-commit==3.5.0 pydantic==1.10.12 pylint==3.0.2 From 60370228f612b846ba41c8389e5922916a2933cd Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 24 Nov 2023 10:09:57 +0100 Subject: [PATCH 694/982] Update nibe heatpump dependency to 2.5.1 (#104429) --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 73c4dc51089..76341eca627 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.5.0"] + "requirements": ["nibe==2.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f9b8c6d4852..ec18b4aaeb5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1313,7 +1313,7 @@ nextcord==2.0.0a8 nextdns==2.0.1 # homeassistant.components.nibe_heatpump -nibe==2.5.0 +nibe==2.5.1 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bcff0b3e61..f6a6b72c70d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1028,7 +1028,7 @@ nextcord==2.0.0a8 nextdns==2.0.1 # homeassistant.components.nibe_heatpump -nibe==2.5.0 +nibe==2.5.1 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 114ca709617ec32de344a1456398c9d7ef88d4fe Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 24 Nov 2023 01:12:00 -0800 Subject: [PATCH 695/982] Bump gcal_sync to 6.0.3 (#104423) --- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index fc9107bb8d2..27e462a380e 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.0.1", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ec18b4aaeb5..19dba37b23b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -862,7 +862,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==6.0.1 +gcal-sync==6.0.3 # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6a6b72c70d..fbc10c2b45d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -685,7 +685,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==6.0.1 +gcal-sync==6.0.3 # homeassistant.components.geocaching geocachingapi==0.2.1 From e03ccb5ab624327031011adf3c82575ee597eced Mon Sep 17 00:00:00 2001 From: Isak Nyberg <36712644+IsakNyberg@users.noreply.github.com> Date: Fri, 24 Nov 2023 10:40:59 +0100 Subject: [PATCH 696/982] Add Mypermobil integration (#95613) Co-authored-by: Franck Nijhof Co-authored-by: Robert Resch --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/permobil/__init__.py | 63 ++++ .../components/permobil/config_flow.py | 173 +++++++++++ homeassistant/components/permobil/const.py | 11 + .../components/permobil/coordinator.py | 57 ++++ .../components/permobil/manifest.json | 9 + homeassistant/components/permobil/sensor.py | 222 ++++++++++++++ .../components/permobil/strings.json | 70 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/permobil/__init__.py | 1 + tests/components/permobil/conftest.py | 27 ++ tests/components/permobil/const.py | 5 + tests/components/permobil/test_config_flow.py | 288 ++++++++++++++++++ 17 files changed, 944 insertions(+) create mode 100644 homeassistant/components/permobil/__init__.py create mode 100644 homeassistant/components/permobil/config_flow.py create mode 100644 homeassistant/components/permobil/const.py create mode 100644 homeassistant/components/permobil/coordinator.py create mode 100644 homeassistant/components/permobil/manifest.json create mode 100644 homeassistant/components/permobil/sensor.py create mode 100644 homeassistant/components/permobil/strings.json create mode 100644 tests/components/permobil/__init__.py create mode 100644 tests/components/permobil/conftest.py create mode 100644 tests/components/permobil/const.py create mode 100644 tests/components/permobil/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 684498f33a9..f28ef24e4b2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -931,6 +931,9 @@ omit = homeassistant/components/panasonic_viera/media_player.py homeassistant/components/pandora/media_player.py homeassistant/components/pencom/switch.py + homeassistant/components/permobil/__init__.py + homeassistant/components/permobil/coordinator.py + homeassistant/components/permobil/sensor.py homeassistant/components/philips_js/__init__.py homeassistant/components/philips_js/light.py homeassistant/components/philips_js/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 342f0d35a9b..d7c8eca064c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -944,6 +944,8 @@ build.json @home-assistant/supervisor /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 /tests/components/pegel_online/ @mib1185 +/homeassistant/components/permobil/ @IsakNyberg +/tests/components/permobil/ @IsakNyberg /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/philips_js/ @elupus diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py new file mode 100644 index 00000000000..2f3c4c04c50 --- /dev/null +++ b/homeassistant/components/permobil/__init__.py @@ -0,0 +1,63 @@ +"""The MyPermobil integration.""" +from __future__ import annotations + +import logging + +from mypermobil import MyPermobil, MyPermobilClientException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_CODE, + CONF_EMAIL, + CONF_REGION, + CONF_TOKEN, + CONF_TTL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .const import APPLICATION, DOMAIN +from .coordinator import MyPermobilCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up MyPermobil from a config entry.""" + + # create the API object from the config and save it in hass + session = hass.helpers.aiohttp_client.async_get_clientsession() + p_api = MyPermobil( + application=APPLICATION, + session=session, + email=entry.data[CONF_EMAIL], + region=entry.data[CONF_REGION], + code=entry.data[CONF_CODE], + token=entry.data[CONF_TOKEN], + expiration_date=entry.data[CONF_TTL], + ) + try: + p_api.self_authenticate() + except MyPermobilClientException as err: + _LOGGER.error("Error authenticating %s", err) + raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err + + # create the coordinator with the API object + coordinator = MyPermobilCoordinator(hass, p_api) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py new file mode 100644 index 00000000000..644ea29d8a3 --- /dev/null +++ b/homeassistant/components/permobil/config_flow.py @@ -0,0 +1,173 @@ +"""Config flow for MyPermobil integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from mypermobil import MyPermobil, MyPermobilAPIException, MyPermobilClientException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL +from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import APPLICATION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +GET_EMAIL_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + } +) + +GET_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_CODE): cv.string}) + + +class PermobilConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Permobil config flow.""" + + VERSION = 1 + region_names: dict[str, str] = {} + data: dict[str, str] = {} + + def __init__(self) -> None: + """Initialize flow.""" + hass: HomeAssistant = async_get_hass() + session = async_get_clientsession(hass) + self.p_api = MyPermobil(APPLICATION, session=session) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Invoke when a user initiates a flow via the user interface.""" + errors: dict[str, str] = {} + + if user_input: + try: + self.p_api.set_email(user_input[CONF_EMAIL]) + except MyPermobilClientException: + _LOGGER.exception("Error validating email") + errors["base"] = "invalid_email" + + self.data.update(user_input) + + await self.async_set_unique_id(self.data[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + if errors or not user_input: + return self.async_show_form( + step_id="user", data_schema=GET_EMAIL_SCHEMA, errors=errors + ) + return await self.async_step_region() + + async def async_step_region( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Invoke when a user initiates a flow via the user interface.""" + errors: dict[str, str] = {} + if not user_input: + # fetch the list of regions names and urls from the api + # for the user to select from. + try: + self.region_names = await self.p_api.request_region_names() + _LOGGER.debug( + "region names %s", + ",".join(list(self.region_names.keys())), + ) + except MyPermobilAPIException: + _LOGGER.exception("Error requesting regions") + errors["base"] = "region_fetch_error" + + else: + region_url = self.region_names[user_input[CONF_REGION]] + + self.data[CONF_REGION] = region_url + self.p_api.set_region(region_url) + _LOGGER.debug("region %s", self.p_api.region) + try: + # tell backend to send code to the users email + await self.p_api.request_application_code() + except MyPermobilAPIException: + _LOGGER.exception("Error requesting code") + errors["base"] = "code_request_error" + + if errors or not user_input: + # the error could either be that the fetch region did not pass + # or that the request application code failed + schema = vol.Schema( + { + vol.Required(CONF_REGION): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(self.region_names.keys()), + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + return self.async_show_form( + step_id="region", data_schema=schema, errors=errors + ) + + return await self.async_step_email_code() + + async def async_step_email_code( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Second step in config flow to enter the email code.""" + errors: dict[str, str] = {} + + if user_input: + try: + self.p_api.set_code(user_input[CONF_CODE]) + self.data.update(user_input) + token, ttl = await self.p_api.request_application_token() + self.data[CONF_TOKEN] = token + self.data[CONF_TTL] = ttl + except (MyPermobilAPIException, MyPermobilClientException): + # the code did not pass validation by the api client + # or the backend returned an error when trying to validate the code + _LOGGER.exception("Error verifying code") + errors["base"] = "invalid_code" + + if errors or not user_input: + return self.async_show_form( + step_id="email_code", data_schema=GET_TOKEN_SCHEMA, errors=errors + ) + + return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert reauth_entry + + try: + email: str = reauth_entry.data[CONF_EMAIL] + region: str = reauth_entry.data[CONF_REGION] + self.p_api.set_email(email) + self.p_api.set_region(region) + self.data = { + CONF_EMAIL: email, + CONF_REGION: region, + } + await self.p_api.request_application_code() + except MyPermobilAPIException: + _LOGGER.exception("Error requesting code for reauth") + return self.async_abort(reason="unknown") + + return await self.async_step_email_code() diff --git a/homeassistant/components/permobil/const.py b/homeassistant/components/permobil/const.py new file mode 100644 index 00000000000..fd5fe673f2a --- /dev/null +++ b/homeassistant/components/permobil/const.py @@ -0,0 +1,11 @@ +"""Constants for the MyPermobil integration.""" + +DOMAIN = "permobil" + +APPLICATION = "Home Assistant" + + +BATTERY_ASSUMED_VOLTAGE = 25.0 # This is the average voltage over all states of charge +REGIONS = "regions" +KM = "kilometers" +MILES = "miles" diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py new file mode 100644 index 00000000000..3695236cdf0 --- /dev/null +++ b/homeassistant/components/permobil/coordinator.py @@ -0,0 +1,57 @@ +"""DataUpdateCoordinator for permobil integration.""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +import logging + +from mypermobil import MyPermobil, MyPermobilAPIException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MyPermobilData: + """MyPermobil data stored in the DataUpdateCoordinator.""" + + battery: dict[str, str | float | int | list | dict] + daily_usage: dict[str, str | float | int | list | dict] + records: dict[str, str | float | int | list | dict] + + +class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): + """MyPermobil coordinator.""" + + def __init__(self, hass: HomeAssistant, p_api: MyPermobil) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="permobil", + update_interval=timedelta(minutes=5), + ) + self.p_api = p_api + + async def _async_update_data(self) -> MyPermobilData: + """Fetch data from the 3 API endpoints.""" + try: + async with asyncio.timeout(10): + battery = await self.p_api.get_battery_info() + daily_usage = await self.p_api.get_daily_usage() + records = await self.p_api.get_usage_records() + return MyPermobilData( + battery=battery, + daily_usage=daily_usage, + records=records, + ) + + except MyPermobilAPIException as err: + _LOGGER.exception( + "Error fetching data from MyPermobil API for account %s %s", + self.p_api.email, + err, + ) + raise UpdateFailed from err diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json new file mode 100644 index 00000000000..fd937fc6f8a --- /dev/null +++ b/homeassistant/components/permobil/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "permobil", + "name": "MyPermobil", + "codeowners": ["@IsakNyberg"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/permobil", + "iot_class": "cloud_polling", + "requirements": ["mypermobil==0.1.6"] +} diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py new file mode 100644 index 00000000000..e942aa265b8 --- /dev/null +++ b/homeassistant/components/permobil/sensor.py @@ -0,0 +1,222 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from mypermobil import ( + BATTERY_AMPERE_HOURS_LEFT, + BATTERY_CHARGE_TIME_LEFT, + BATTERY_DISTANCE_LEFT, + BATTERY_INDOOR_DRIVE_TIME, + BATTERY_MAX_AMPERE_HOURS, + BATTERY_MAX_DISTANCE_LEFT, + BATTERY_STATE_OF_CHARGE, + BATTERY_STATE_OF_HEALTH, + RECORDS_SEATING, + USAGE_ADJUSTMENTS, + USAGE_DISTANCE, +) + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN +from .coordinator import MyPermobilCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class PermobilRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], float | int] + available_fn: Callable[[Any], bool] + + +@dataclass +class PermobilSensorEntityDescription( + SensorEntityDescription, PermobilRequiredKeysMixin +): + """Describes Permobil sensor entity.""" + + +SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( + PermobilSensorEntityDescription( + # Current battery as a percentage + value_fn=lambda data: data.battery[BATTERY_STATE_OF_CHARGE[0]], + available_fn=lambda data: BATTERY_STATE_OF_CHARGE[0] in data.battery, + key="state_of_charge", + translation_key="state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Current battery health as a percentage of original capacity + value_fn=lambda data: data.battery[BATTERY_STATE_OF_HEALTH[0]], + available_fn=lambda data: BATTERY_STATE_OF_HEALTH[0] in data.battery, + key="state_of_health", + translation_key="state_of_health", + icon="mdi:battery-heart-variant", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Time until fully charged (displays 0 if not charging) + value_fn=lambda data: data.battery[BATTERY_CHARGE_TIME_LEFT[0]], + available_fn=lambda data: BATTERY_CHARGE_TIME_LEFT[0] in data.battery, + key="charge_time_left", + translation_key="charge_time_left", + icon="mdi:battery-clock", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + PermobilSensorEntityDescription( + # Distance possible on current change (km) + value_fn=lambda data: data.battery[BATTERY_DISTANCE_LEFT[0]], + available_fn=lambda data: BATTERY_DISTANCE_LEFT[0] in data.battery, + key="distance_left", + translation_key="distance_left", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + ), + PermobilSensorEntityDescription( + # Drive time possible on current charge + value_fn=lambda data: data.battery[BATTERY_INDOOR_DRIVE_TIME[0]], + available_fn=lambda data: BATTERY_INDOOR_DRIVE_TIME[0] in data.battery, + key="indoor_drive_time", + translation_key="indoor_drive_time", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + PermobilSensorEntityDescription( + # Watt hours the battery can store given battery health + value_fn=lambda data: data.battery[BATTERY_MAX_AMPERE_HOURS[0]] + * BATTERY_ASSUMED_VOLTAGE, + available_fn=lambda data: BATTERY_MAX_AMPERE_HOURS[0] in data.battery, + key="max_watt_hours", + translation_key="max_watt_hours", + icon="mdi:lightning-bolt", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Current amount of watt hours in battery + value_fn=lambda data: data.battery[BATTERY_AMPERE_HOURS_LEFT[0]] + * BATTERY_ASSUMED_VOLTAGE, + available_fn=lambda data: BATTERY_AMPERE_HOURS_LEFT[0] in data.battery, + key="watt_hours_left", + translation_key="watt_hours_left", + icon="mdi:lightning-bolt", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Distance that can be traveled with full charge given battery health (km) + value_fn=lambda data: data.battery[BATTERY_MAX_DISTANCE_LEFT[0]], + available_fn=lambda data: BATTERY_MAX_DISTANCE_LEFT[0] in data.battery, + key="max_distance_left", + translation_key="max_distance_left", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + ), + PermobilSensorEntityDescription( + # Distance traveled today monotonically increasing, resets every 24h (km) + value_fn=lambda data: data.daily_usage[USAGE_DISTANCE[0]], + available_fn=lambda data: USAGE_DISTANCE[0] in data.daily_usage, + key="usage_distance", + translation_key="usage_distance", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + PermobilSensorEntityDescription( + # Number of adjustments monotonically increasing, resets every 24h + value_fn=lambda data: data.daily_usage[USAGE_ADJUSTMENTS[0]], + available_fn=lambda data: USAGE_ADJUSTMENTS[0] in data.daily_usage, + key="usage_adjustments", + translation_key="usage_adjustments", + icon="mdi:seat-recline-extra", + native_unit_of_measurement="adjustments", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + PermobilSensorEntityDescription( + # Largest number of adjustemnts in a single 24h period, never resets + value_fn=lambda data: data.records[RECORDS_SEATING[0]], + available_fn=lambda data: RECORDS_SEATING[0] in data.records, + key="record_adjustments", + translation_key="record_adjustments", + icon="mdi:seat-recline-extra", + native_unit_of_measurement="adjustments", + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create sensors from a config entry created in the integrations UI.""" + + coordinator: MyPermobilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + PermobilSensor(coordinator=coordinator, description=description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PermobilSensor(CoordinatorEntity[MyPermobilCoordinator], SensorEntity): + """Representation of a Sensor. + + This implements the common functions of all sensors. + """ + + _attr_has_entity_name = True + _attr_suggested_display_precision = 0 + entity_description: PermobilSensorEntityDescription + _available = True + + def __init__( + self, + coordinator: MyPermobilCoordinator, + description: PermobilSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.p_api.email}_{self.entity_description.key}" + ) + + @property + def available(self) -> bool: + """Return True if the sensor has value.""" + return super().available and self.entity_description.available_fn( + self.coordinator.data + ) + + @property + def native_value(self) -> float | int: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json new file mode 100644 index 00000000000..b0b630eff08 --- /dev/null +++ b/homeassistant/components/permobil/strings.json @@ -0,0 +1,70 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Enter your permobil email" + } + }, + "email_code": { + "description": "Enter the code that was sent to your email.", + "data": { + "code": "Email code" + } + }, + "region": { + "description": "Select the region of your account.", + "data": { + "code": "Region" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "unknown": "Unexpected error, more information in the logs", + "region_fetch_error": "Error fetching regions", + "code_request_error": "Error requesting application code", + "invalid_email": "Invalid email", + "invalid_code": "The code you gave is incorrect" + } + }, + "entity": { + "sensor": { + "state_of_charge": { + "name": "Battery charge" + }, + "state_of_health": { + "name": "Battery health" + }, + "charge_time_left": { + "name": "Charge time left" + }, + "distance_left": { + "name": "Distance left" + }, + "indoor_drive_time": { + "name": "Indoor drive time" + }, + "max_watt_hours": { + "name": "Battery max watt hours" + }, + "watt_hours_left": { + "name": "Watt hours left" + }, + "max_distance_left": { + "name": "Full charge distance" + }, + "usage_distance": { + "name": "Distance traveled" + }, + "usage_adjustments": { + "name": "Number of adjustments" + }, + "record_adjustments": { + "name": "Record number of adjustments" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3bbed6d145b..3aa738731b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -354,6 +354,7 @@ FLOWS = { "panasonic_viera", "peco", "pegel_online", + "permobil", "philips_js", "pi_hole", "picnic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 50fb66c5f59..00ec549fd6d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4242,6 +4242,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "permobil": { + "name": "MyPermobil", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "pge": { "name": "Pacific Gas & Electric (PG&E)", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 19dba37b23b..fd0c3225958 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1279,6 +1279,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.permobil +mypermobil==0.1.6 + # homeassistant.components.nad nad-receiver==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbc10c2b45d..f440046b5a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1003,6 +1003,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.permobil +mypermobil==0.1.6 + # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 diff --git a/tests/components/permobil/__init__.py b/tests/components/permobil/__init__.py new file mode 100644 index 00000000000..56e779eef4d --- /dev/null +++ b/tests/components/permobil/__init__.py @@ -0,0 +1 @@ +"""Tests for the MyPermobil integration.""" diff --git a/tests/components/permobil/conftest.py b/tests/components/permobil/conftest.py new file mode 100644 index 00000000000..2dcf9bd5ad2 --- /dev/null +++ b/tests/components/permobil/conftest.py @@ -0,0 +1,27 @@ +"""Common fixtures for the MyPermobil tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from mypermobil import MyPermobil +import pytest + +from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.permobil.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def my_permobil() -> Mock: + """Mock spec for MyPermobilApi.""" + mock = Mock(spec=MyPermobil) + mock.request_region_names.return_value = {MOCK_REGION_NAME: MOCK_URL} + mock.request_application_token.return_value = MOCK_TOKEN + mock.region = "" + return mock diff --git a/tests/components/permobil/const.py b/tests/components/permobil/const.py new file mode 100644 index 00000000000..cb8a0c32f17 --- /dev/null +++ b/tests/components/permobil/const.py @@ -0,0 +1,5 @@ +"""Test constants for Permobil.""" + +MOCK_URL = "https://example.com" +MOCK_REGION_NAME = "region_name" +MOCK_TOKEN = ("a" * 256, "date") diff --git a/tests/components/permobil/test_config_flow.py b/tests/components/permobil/test_config_flow.py new file mode 100644 index 00000000000..ad61ead7bfc --- /dev/null +++ b/tests/components/permobil/test_config_flow.py @@ -0,0 +1,288 @@ +"""Test the MyPermobil config flow.""" +from unittest.mock import Mock, patch + +from mypermobil import MyPermobilAPIException, MyPermobilClientException +import pytest + +from homeassistant import config_entries +from homeassistant.components.permobil import config_flow +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_CODE = "012345" +MOCK_EMAIL = "valid@email.com" +INVALID_EMAIL = "this is not a valid email" +VALID_DATA = { + CONF_EMAIL: MOCK_EMAIL, + CONF_REGION: MOCK_URL, + CONF_CODE: MOCK_CODE, + CONF_TOKEN: MOCK_TOKEN[0], + CONF_TTL: MOCK_TOKEN[1], +} + + +async def test_sucessful_config_flow(hass: HomeAssistant, my_permobil: Mock) -> None: + """Test the config flow from start to finish with no errors.""" + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + # request region code + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == VALID_DATA + + +async def test_config_flow_incorrect_code( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until email code verification and have the API return error.""" + my_permobil.request_application_token.side_effect = MyPermobilAPIException + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request region code + # here the request_application_token raises a MyPermobilAPIException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"]["base"] == "invalid_code" + + +async def test_config_flow_incorrect_region( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for email code and have the API return error.""" + my_permobil.request_application_code.side_effect = MyPermobilAPIException + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + # here the request_application_code raises a MyPermobilAPIException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"]["base"] == "code_request_error" + + +async def test_config_flow_region_request_error( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for regions and have the API return error.""" + my_permobil.request_region_names.side_effect = MyPermobilAPIException + # init flow + # here the request_region_names raises a MyPermobilAPIException + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"]["base"] == "region_fetch_error" + + +async def test_config_flow_invalid_email( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for regions and have the API return error.""" + my_permobil.set_email.side_effect = MyPermobilClientException() + # init flow + # here the set_email raises a MyPermobilClientException + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: INVALID_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + assert result["errors"]["base"] == "invalid_email" + + +async def test_config_flow_reauth_success( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth make sure that the values are replaced.""" + # new token and code + reauth_token = ("b" * 256, "reauth_date") + reauth_code = "567890" + my_permobil.request_application_token.return_value = reauth_token + + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": mock_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request request new token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: reauth_code}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_EMAIL: MOCK_EMAIL, + CONF_REGION: MOCK_URL, + CONF_CODE: reauth_code, + CONF_TOKEN: reauth_token[0], + CONF_TTL: reauth_token[1], + } + + +async def test_config_flow_reauth_fail_invalid_code( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth when the email code fails.""" + # new code + reauth_invalid_code = "567890" # pretend this code is invalid/incorrect + my_permobil.request_application_token.side_effect = MyPermobilAPIException + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": mock_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request request new token but have the API return error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: reauth_invalid_code}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"]["base"] == "invalid_code" + + +async def test_config_flow_reauth_fail_code_request( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth.""" + my_permobil.request_application_code.side_effect = MyPermobilAPIException + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + # test the reauth and have request_application_code fail leading to an abort + my_permobil.request_application_code.side_effect = MyPermobilAPIException + reauth_entry = hass.config_entries.async_entries(config_flow.DOMAIN)[0] + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": reauth_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From b3211fa5eee08c4f45f836976d926e7d69538c95 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 24 Nov 2023 10:56:17 +0100 Subject: [PATCH 697/982] Clean mqtt patch.dict config entries (#104449) --- tests/components/mqtt/test_discovery.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 50809a11fc1..017d24a39ce 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -34,6 +34,7 @@ from tests.common import ( MockConfigEntry, async_capture_events, async_fire_mqtt_message, + mock_config_flow, mock_platform, ) from tests.typing import ( @@ -1522,7 +1523,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( """Test mqtt step.""" return self.async_abort(reason="already_configured") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): await asyncio.sleep(0) assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called @@ -1575,7 +1576,7 @@ async def test_mqtt_discovery_unsubscribe_once( """Test mqtt step.""" return self.async_abort(reason="already_configured") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") await asyncio.sleep(0.1) From a3eb44209d7c3885ab50ef43e6b9d064ed3b1cc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Nov 2023 10:58:05 +0100 Subject: [PATCH 698/982] Bump github/codeql-action from 2.22.7 to 2.22.8 (#104444) 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 7aa10a29762..e7d9d4cd901 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.7 + uses: github/codeql-action/init@v2.22.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.7 + uses: github/codeql-action/analyze@v2.22.8 with: category: "/language:python" From d4450c6c5556d00f515eeb6093d2704ee0e0bfdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Geir=20R=C3=A5ness?= Date: Fri, 24 Nov 2023 11:01:15 +0100 Subject: [PATCH 699/982] Add Z-wave climate sensor override for Heatit Z-TRM6 (#103896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add some basic overrides for z-trm6 * switched id and type * add fixtures some lints * duplicate tests of z-trm3 * add broken test for trm6 * fix tests, remove name from fixtures, fix comment * lints * forgot lints * add better description on pwer mode * Update comment v2 Co-authored-by: Martin Hjelmare * fix space from github web merge * lint on fixture * fix permissions on fixture --------- Co-authored-by: geir råness <11741725+geirra@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 62 + tests/components/zwave_js/conftest.py | 14 + .../fixtures/climate_heatit_z_trm6_state.json | 2120 +++++++++++++++++ tests/components/zwave_js/test_climate.py | 71 + 4 files changed, 2267 insertions(+) create mode 100644 tests/components/zwave_js/fixtures/climate_heatit_z_trm6_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index ab1d0660cca..dfe2294e710 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -530,6 +530,68 @@ DISCOVERY_SCHEMAS = [ primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, assumed_state=True, ), + # Heatit Z-TRM6 + ZWaveDiscoverySchema( + platform=Platform.CLIMATE, + hint="dynamic_current_temp", + manufacturer_id={0x019B}, + product_id={0x3001}, + product_type={0x0030}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.THERMOSTAT_MODE}, + property={THERMOSTAT_MODE_PROPERTY}, + type={ValueType.NUMBER}, + ), + data_template=DynamicCurrentTempClimateDataTemplate( + lookup_table={ + # Floor sensor + "Floor": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=4, + ), + # Internal sensor + "Internal": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=2, + ), + # Internal with limit by floor sensor + "Internal with floor limit": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=2, + ), + # External sensor (connected to device) + "External": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=3, + ), + # External sensor (connected to device) with limit by floor sensor (2x sensors) + "External with floor limit": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=3, + ), + # PWER - Power regulator mode (no sensor used). + # This mode is not supported by the climate entity. + # Heating is set by adjusting parameter 25. + # P25: Set % of time the relay should be active when using PWER mode. + # (30-minute duty cycle) + # Use the air temperature as current temperature in the climate entity + # as we have nothing else. + "Power regulator": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=2, + ), + }, + dependent_value=ZwaveValueID( + property_=2, command_class=CommandClass.CONFIGURATION, endpoint=0 + ), + ), + ), # Heatit Z-TRM3 ZWaveDiscoverySchema( platform=Platform.CLIMATE, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 5a424b38c5b..f2c3abd362a 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -385,6 +385,12 @@ def climate_eurotronic_spirit_z_state_fixture(): return json.loads(load_fixture("zwave_js/climate_eurotronic_spirit_z_state.json")) +@pytest.fixture(name="climate_heatit_z_trm6_state", scope="session") +def climate_heatit_z_trm6_state_fixture(): + """Load the climate HEATIT Z-TRM6 thermostat node state fixture data.""" + return json.loads(load_fixture("zwave_js/climate_heatit_z_trm6_state.json")) + + @pytest.fixture(name="climate_heatit_z_trm3_state", scope="session") def climate_heatit_z_trm3_state_fixture(): """Load the climate HEATIT Z-TRM3 thermostat node state fixture data.""" @@ -897,6 +903,14 @@ def climate_eurotronic_spirit_z_fixture(client, climate_eurotronic_spirit_z_stat return node +@pytest.fixture(name="climate_heatit_z_trm6") +def climate_heatit_z_trm6_fixture(client, climate_heatit_z_trm6_state): + """Mock a climate radio HEATIT Z-TRM6 node.""" + node = Node(client, copy.deepcopy(climate_heatit_z_trm6_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="climate_heatit_z_trm3_no_value") def climate_heatit_z_trm3_no_value_fixture( client, climate_heatit_z_trm3_no_value_state diff --git a/tests/components/zwave_js/fixtures/climate_heatit_z_trm6_state.json b/tests/components/zwave_js/fixtures/climate_heatit_z_trm6_state.json new file mode 100644 index 00000000000..ffc7b25fda4 --- /dev/null +++ b/tests/components/zwave_js/fixtures/climate_heatit_z_trm6_state.json @@ -0,0 +1,2120 @@ +{ + "nodeId": 101, + "index": 0, + "installerIcon": 4608, + "userIcon": 4609, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 411, + "productId": 12289, + "productType": 48, + "firmwareVersion": "1.0.6", + "zwavePlusVersion": 2, + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/data/db/devices/0x019b/z-trm6.json", + "isEmbedded": true, + "manufacturer": "Heatit", + "manufacturerId": 411, + "label": "Z-TRM6", + "description": "Floor Thermostat", + "devices": [ + { + "productType": 48, + "productId": 12289 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "overrideFloatEncoding": { + "size": 2 + } + }, + "metadata": { + "inclusion": "Add\nThe primary controller/gateway has a mode for adding devices. Please refer to your primary controller manual on how to set the primary controller in add mode. The device may only be added to the network if the primary controller is in add mode.\nAn always listening node must be powered continuously and reside in a fixed position in the installation to secure the routing table. Adding the device within a 2 meter range from the gateway can minimize faults during the Interview process.\n\nStandard (Manual)\nAdd mode is indicated on the device by rotating LED segments on the display. It indicates this for 90 seconds until a timeout occurs, or until the device has been added to the network. Configuration mode can also be cancelled by performing the same procedure used for starting\nConfiguration mode.\n1. Hold the Center button for 5 seconds.\nThe display will show \u201cOFF\u201d.\n2. Press the \u201d+\u201d button once to see \u201cCON\u201d in the display.\n3. Start the add device process in your primary controller.\n4. Start the configuration mode on the thermostat by holding the Center button for approximately 2 seconds.\n\nThe device is now ready for use with default settings.\nIf inclusion fails, please perform a \u201dremove device\u201d process and try again. If inclusion fails again, please see \u201cFactory reset\u201d", + "exclusion": "Remove\nThe primary controller/gateway has a mode for removing devices. Please refer to your primary controller manual on how to set the primary controller in remove mode. The device may only be removed from the network if the primary controller is in remove mode.\nWhen the device is removed from the network, it will NOT revert to factory settings.\n\nStandard (Manual)\nRemove mode is indicated on the device by rotating LED segments on the display. It indicates this for 90 seconds until a timeout occurs, or until the device has been removed from the network. Configuration mode can also be cancelled by performing the same procedure used for starting\nConfiguration mode.\n1. Hold the Center button for 5 seconds.\nThe display will show \u201cOFF\u201d.\n2. Press the \u201d+\u201d button once to see \u201cCON\u201d in the display.\n3. Start the remove device process in your primary controller.\n4. Start the configuration mode on the thermostat by holding the Center button for approximately 2 seconds.\n\nNB! When the device is removed from the gateway, the parameters are not reset. To reset the parameters, see Chapter \u201dFactory reset\u201d", + "reset": "Enter the menu by holding the Center button for about 5 seconds, navigate in the menu with the \u201d+\u201d button til you see FACT. Press the Center button until you see \u201c-- --\u201d blinking in the display, then hold for about 5 seconds to perform a reset.\nYou may also initiate a reset by holding the Right and Center buttons for 60 seconds.\n\nWhen either of these procedures has been performed, the thermostat will perform a complete factory reset. The device will display \u201cRES\u201d for 5 seconds while performing a factory reset. When \u201cRES\u201d is no longer displayed, the thermostat has been reset.\n\nPlease use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://media.heatit.com/2926" + } + }, + "label": "Z-TRM6", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 4, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 6, + "label": "General Thermostat V2" + }, + "mandatorySupportedCCs": [32, 114, 64, 67, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x019b:0x0030:0x3001:1.0.6", + "statistics": { + "commandsTX": 268, + "commandsRX": 399, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 4, + "lastSeen": "2023-11-20T16:45:28.117Z", + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -51, + "repeaterRSSI": [] + }, + "rtt": 32.4, + "rssi": -50 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-11-20T16:45:28.117Z", + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Local Control", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Enable", + "1": "Disable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Sensor Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Sensor Mode", + "default": 1, + "min": 0, + "max": 5, + "states": { + "0": "Floor", + "1": "Internal", + "2": "Internal with floor limit", + "3": "External", + "4": "External with floor limit", + "5": "Power regulator" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "External Sensor Resistance", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "External Sensor Resistance", + "default": 0, + "min": 0, + "max": 7, + "states": { + "0": "10", + "1": "12", + "2": "15", + "3": "22", + "4": "33", + "5": "47", + "6": "6.8", + "7": "100" + }, + "unit": "k\u03a9", + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Internal Sensor Min Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Internal Sensor Min Temp Limit", + "default": 50, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Floor Sensor Min Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Floor Sensor Min Temp Limit", + "default": 50, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "External Sensor Min Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "External Sensor Min Temp Limit", + "default": 50, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Internal Sensor Max Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Internal Sensor Max Temp Limit", + "default": 400, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 400 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Floor Sensor Max Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Floor Sensor Max Temp Limit", + "default": 400, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 400 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "External Sensor Max Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "External Sensor Max Temp Limit", + "default": 400, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 400 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Internal Sensor Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Internal Sensor Calibration", + "default": 0, + "min": -60, + "max": 60, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Floor Sensor Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Floor Sensor Calibration", + "default": 0, + "min": -60, + "max": 60, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "External Sensor Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "External Sensor Calibration", + "default": 0, + "min": -60, + "max": 60, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Regulation Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Regulation Mode", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Hysteresis", + "1": "PWM" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Temperature Control Hysteresis", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Control Hysteresis", + "default": 5, + "min": 3, + "max": 30, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Temperature Display", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Display", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Setpoint", + "1": "Measured" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Active Display Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Active Display Brightness", + "default": 10, + "min": 1, + "max": 10, + "unit": "10 %", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Standby Display Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Standby Display Brightness", + "default": 5, + "min": 1, + "max": 10, + "unit": "10 %", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Temperature Report Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Interval", + "default": 840, + "min": 30, + "max": 65535, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 840 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Temperature Report Hysteresis", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Hysteresis", + "default": 10, + "min": 1, + "max": 100, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Meter Report Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Meter Report Interval", + "default": 840, + "min": 30, + "max": 65535, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 840 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Turn On Delay After Error", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Turn On Delay After Error", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Stay off (Display error)" + }, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "Heating Setpoint", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Heating Setpoint", + "default": 210, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 190 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "Cooling Setpoint", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Cooling Setpoint", + "default": 180, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 180 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Eco Setpoint", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Eco Setpoint", + "default": 180, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 180 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Power Regulator Active Time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power Regulator Active Time", + "default": 2, + "min": 1, + "max": 10, + "unit": "10 %", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyName": "Thermostat State Report Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Thermostat State Report Interval", + "default": 43200, + "min": 0, + "max": 65535, + "states": { + "0": "Changes only" + }, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 43200 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 27, + "propertyName": "Operating Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Operating Mode", + "default": 1, + "min": 0, + "max": 3, + "states": { + "0": "Off", + "1": "Heating", + "2": "Cooling", + "3": "Eco" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 28, + "propertyName": "Open Window Detection", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Open Window Detection", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disabled", + "1": "Enabled" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 29, + "propertyName": "Load Power", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Load Power", + "default": 0, + "min": 0, + "max": 99, + "states": { + "0": "Use measured value" + }, + "unit": "100 W", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 411 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 12289 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.0", "2.5"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.0.6" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0, + "nodeId": 101 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 0, + "nodeId": 101 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 5, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Thermostat mode", + "min": 0, + "max": 255, + "states": { + "0": "Off", + "1": "Heat", + "2": "Cool", + "11": "Energy heat" + }, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 1, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "manufacturerData", + "propertyName": "manufacturerData", + "ccVersion": 3, + "metadata": { + "type": "buffer", + "readable": true, + "writeable": false, + "label": "Manufacturer data", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 66, + "commandClassName": "Thermostat Operating State", + "property": "state", + "propertyName": "state", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Operating state", + "min": 0, + "max": 255, + "states": { + "0": "Idle", + "1": "Heating", + "2": "Cooling", + "3": "Fan Only", + "4": "Pending Heat", + "5": "Pending Cool", + "6": "Vent/Economizer", + "7": "Aux Heating", + "8": "2nd Stage Heating", + "9": "2nd Stage Cooling", + "10": "2nd Stage Aux Heat", + "11": "3rd Stage Aux Heat" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 1, + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Heating)", + "ccSpecific": { + "setpointType": 1 + }, + "min": 5, + "max": 40, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 19 + }, + { + "endpoint": 1, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 2, + "propertyName": "setpoint", + "propertyKeyName": "Cooling", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Cooling)", + "ccSpecific": { + "setpointType": 2 + }, + "min": 5, + "max": 40, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 18 + }, + { + "endpoint": 1, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 11, + "propertyName": "setpoint", + "propertyKeyName": "Energy Save Heating", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Energy Save Heating)", + "ccSpecific": { + "setpointType": 11 + }, + "min": 5, + "max": 40, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 18 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Overheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-load status", + "propertyName": "Power Management", + "propertyKeyName": "Over-load status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-load status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Over-load detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "1": "ProtectedBySequence", + "2": "NoOperationPossible" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 11, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 22.5, + "nodeId": 101 + }, + { + "endpoint": 3, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 11, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 11, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 21.9, + "nodeId": 101 + } + ], + "endpoints": [ + { + "nodeId": 101, + "index": 0, + "installerIcon": 4608, + "userIcon": 4609, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 6, + "label": "General Thermostat V2" + }, + "mandatorySupportedCCs": [32, 114, 64, 67, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 64, + "name": "Thermostat Mode", + "version": 3, + "isSecure": true + }, + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 3, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 66, + "name": "Thermostat Operating State", + "version": 1, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 5, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 117, + "name": "Protection", + "version": 1, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + }, + { + "nodeId": 101, + "index": 1, + "installerIcon": 4608, + "userIcon": 4609, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 6, + "label": "General Thermostat V2" + }, + "mandatorySupportedCCs": [32, 114, 64, 67, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 64, + "name": "Thermostat Mode", + "version": 3, + "isSecure": true + }, + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 3, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 66, + "name": "Thermostat Operating State", + "version": 1, + "isSecure": true + }, + { + "id": 117, + "name": "Protection", + "version": 1, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 5, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + } + ] + }, + { + "nodeId": 101, + "index": 2, + "installerIcon": 3328, + "userIcon": 3329, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + }, + "mandatorySupportedCCs": [32, 49], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + } + ] + }, + { + "nodeId": 101, + "index": 3, + "installerIcon": 3328, + "userIcon": 3329, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + }, + "mandatorySupportedCCs": [32, 49], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + } + ] + }, + { + "nodeId": 101, + "index": 4, + "installerIcon": 3328, + "userIcon": 3329, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + }, + "mandatorySupportedCCs": [32, 49], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index e9040dfd397..d5619ff014c 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -415,6 +415,77 @@ async def test_setpoint_thermostat( client.async_send_command_no_wait.reset_mock() +async def test_thermostat_heatit_z_trm6( + hass: HomeAssistant, client, climate_heatit_z_trm6, integration +) -> None: + """Test a heatit Z-TRM6 entity.""" + node = climate_heatit_z_trm6 + state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) + + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + ] + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5 + assert state.attributes[ATTR_TEMPERATURE] == 19 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + assert state.attributes[ATTR_MIN_TEMP] == 5 + assert state.attributes[ATTR_MAX_TEMP] == 40 + + # Try switching to external sensor (not connected so defaults to 0) + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 101, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 2, + "propertyName": "Sensor mode", + "newValue": 4, + "prevValue": 2, + }, + }, + ) + node.receive_event(event) + state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 0 + + # Try switching to floor sensor + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 101, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 2, + "propertyName": "Sensor mode", + "newValue": 0, + "prevValue": 4, + }, + }, + ) + node.receive_event(event) + state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.9 + + async def test_thermostat_heatit_z_trm3_no_value( hass: HomeAssistant, client, climate_heatit_z_trm3_no_value, integration ) -> None: From 6271fe333d888c6145cd3478e4976160557445a4 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 24 Nov 2023 11:18:55 +0100 Subject: [PATCH 700/982] Rework some UniFi unique IDs (#104390) --- .../components/unifi/device_tracker.py | 32 +++++++- homeassistant/components/unifi/switch.py | 42 ++++++++--- tests/components/unifi/test_device_tracker.py | 9 ++- tests/components/unifi/test_switch.py | 75 ++++++++++++++++++- 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 5c9694c669c..1be52b97974 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -17,14 +17,15 @@ from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import DOMAIN, ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er import homeassistant.util.dt as dt_util -from .controller import UniFiController +from .controller import UNIFI_DOMAIN, UniFiController from .entity import ( HandlerT, UnifiEntity, @@ -175,7 +176,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"{obj_id}-{controller.site}", + unique_id_fn=lambda controller, obj_id: f"{controller.site}-{obj_id}", ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip, hostname_fn=lambda api, obj_id: api.clients[obj_id].hostname, ), @@ -201,12 +202,37 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( ) +@callback +def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Normalize client unique ID to have a prefix rather than suffix. + + Introduced with release 2023.12. + """ + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + ent_reg = er.async_get(hass) + + @callback + def update_unique_id(obj_id: str) -> None: + """Rework unique ID.""" + new_unique_id = f"{controller.site}-{obj_id}" + if ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, new_unique_id): + return + + unique_id = f"{obj_id}-{controller.site}" + if entity_id := ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, unique_id): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + + for obj_id in list(controller.api.clients) + list(controller.api.clients_all): + update_unique_id(obj_id) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" + async_update_unique_id(hass, config_entry) UniFiController.register_platform( hass, config_entry, async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 18a3dbc3b90..1e9ec8b14c8 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -42,9 +42,10 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from .const import ATTR_MANUFACTURER -from .controller import UniFiController +from .controller import UNIFI_DOMAIN, UniFiController from .entity import ( HandlerT, SubscriptionT, @@ -199,12 +200,6 @@ class UnifiSwitchEntityDescription( only_event_for_state_change: bool = False -def _make_unique_id(obj_id: str, type_name: str) -> str: - """Split an object id by the first underscore and interpose the given type.""" - prefix, _, suffix = obj_id.partition("_") - return f"{prefix}-{type_name}-{suffix}" - - ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( UnifiSwitchEntityDescription[Clients, Client]( key="Block client", @@ -262,7 +257,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=False, supported_fn=async_outlet_supports_switching_fn, - unique_id_fn=lambda controller, obj_id: _make_unique_id(obj_id, "outlet"), + unique_id_fn=lambda controller, obj_id: f"outlet-{obj_id}", ), UnifiSwitchEntityDescription[PortForwarding, PortForward]( key="Port forward control", @@ -303,7 +298,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.ports[obj_id], should_poll=False, supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, - unique_id_fn=lambda controller, obj_id: _make_unique_id(obj_id, "poe"), + unique_id_fn=lambda controller, obj_id: f"poe-{obj_id}", ), UnifiSwitchEntityDescription[Wlans, Wlan]( key="WLAN control", @@ -328,12 +323,41 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ) +@callback +def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Normalize switch unique ID to have a prefix rather than midfix. + + Introduced with release 2023.12. + """ + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + ent_reg = er.async_get(hass) + + @callback + def update_unique_id(obj_id: str, type_name: str) -> None: + """Rework unique ID.""" + new_unique_id = f"{type_name}-{obj_id}" + if ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, new_unique_id): + return + + prefix, _, suffix = obj_id.partition("_") + unique_id = f"{prefix}-{type_name}-{suffix}" + if entity_id := ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, unique_id): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + + for obj_id in controller.api.outlets: + update_unique_id(obj_id, "outlet") + + for obj_id in controller.api.ports: + update_unique_id(obj_id, "poe") + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" + async_update_unique_id(hass, config_entry) UniFiController.register_platform( hass, config_entry, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index cbff868d9a6..abe12a1e243 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -939,13 +939,20 @@ async def test_restoring_client( ) registry = er.async_get(hass) - registry.async_get_or_create( + registry.async_get_or_create( # Unique ID updated TRACKER_DOMAIN, UNIFI_DOMAIN, f'{restored["mac"]}-site_id', suggested_object_id=restored["hostname"], config_entry=config_entry, ) + registry.async_get_or_create( # Unique ID already updated + TRACKER_DOMAIN, + UNIFI_DOMAIN, + f'site_id-{client["mac"]}', + suggested_object_id=client["hostname"], + config_entry=config_entry, + ) await setup_unifi_integration( hass, diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index fe2ee5dc9e8..00ebcd0e683 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -5,6 +5,7 @@ from datetime import timedelta from aiounifi.models.message import MessageKey import pytest +from homeassistant import config_entries from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -32,7 +33,12 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .test_controller import CONTROLLER_HOST, SITE, setup_unifi_integration +from .test_controller import ( + CONTROLLER_HOST, + ENTRY_CONFIG, + SITE, + setup_unifi_integration, +) from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -1585,3 +1591,70 @@ async def test_port_forwarding_switches( mock_unifi_websocket(message=MessageKey.PORT_FORWARD_DELETED, data=_data) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + +async def test_updating_unique_id( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Verify outlet control and poe control unique ID update works.""" + poe_device = { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "switch", + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_caps": 7, + "poe_class": "Class 4", + "poe_enable": True, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": True, + "up": True, + }, + ], + } + + config_entry = config_entries.ConfigEntry( + version=1, + domain=UNIFI_DOMAIN, + title="Mock Title", + data=ENTRY_CONFIG, + source="test", + options={}, + entry_id="1", + ) + + registry = er.async_get(hass) + registry.async_get_or_create( + SWITCH_DOMAIN, + UNIFI_DOMAIN, + f'{poe_device["mac"]}-poe-1', + suggested_object_id="switch_port_1_poe", + config_entry=config_entry, + ) + registry.async_get_or_create( + SWITCH_DOMAIN, + UNIFI_DOMAIN, + f'{OUTLET_UP1["mac"]}-outlet-1', + suggested_object_id="plug_outlet_1", + config_entry=config_entry, + ) + + await setup_unifi_integration( + hass, aioclient_mock, devices_response=[poe_device, OUTLET_UP1] + ) + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 + assert hass.states.get("switch.switch_port_1_poe") + assert hass.states.get("switch.plug_outlet_1") From 6f54aaf564d9cbca439fcac301bbb57bd7c5ecad Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 24 Nov 2023 11:20:34 +0100 Subject: [PATCH 701/982] Introduce base entity for ping (#104197) --- .../components/ping/binary_sensor.py | 12 ++---- .../components/ping/device_tracker.py | 42 +++++++------------ homeassistant/components/ping/entity.py | 28 +++++++++++++ .../ping/snapshots/test_binary_sensor.ambr | 34 +++++++++++++-- tests/components/ping/test_binary_sensor.py | 11 ++++- tests/components/ping/test_device_tracker.py | 23 ++++++++++ 6 files changed, 111 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/ping/entity.py diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 97636111586..e6cad32f3de 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -18,11 +18,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN from .coordinator import PingUpdateCoordinator +from .entity import BasePingEntity _LOGGER = logging.getLogger(__name__) @@ -84,20 +84,16 @@ async def async_setup_entry( async_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) -class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEntity): +class PingBinarySensor(BasePingEntity, BinarySensorEntity): """Representation of a Ping Binary sensor.""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - _attr_available = False def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping Binary sensor.""" - super().__init__(coordinator) - - self._attr_name = config_entry.title - self._attr_unique_id = config_entry.entry_id + super().__init__(config_entry, coordinator) # if this was imported just enable it when it was enabled before if CONF_IMPORTED_BY in config_entry.data: @@ -112,7 +108,7 @@ class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEnt @property def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the ICMP checo request.""" + """Return the state attributes of the ICMP echo request.""" if self.coordinator.data.data is None: return None return { diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index ceff1b2e124..1bce965ee55 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -8,21 +8,26 @@ import voluptuous as vol from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, AsyncSeeCallback, - ScannerEntity, SourceType, ) +from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME +from homeassistant.const import ( + CONF_HOST, + CONF_HOSTS, + CONF_NAME, + STATE_HOME, + STATE_NOT_HOME, +) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN -from .coordinator import PingUpdateCoordinator +from .entity import BasePingEntity _LOGGER = logging.getLogger(__name__) @@ -84,37 +89,20 @@ async def async_setup_entry( async_add_entities([PingDeviceTracker(entry, data.coordinators[entry.entry_id])]) -class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): +class PingDeviceTracker(BasePingEntity, BaseTrackerEntity): """Representation of a Ping device tracker.""" - def __init__( - self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator - ) -> None: - """Initialize the Ping device tracker.""" - super().__init__(coordinator) - - self._attr_name = config_entry.title - self.config_entry = config_entry - - @property - def ip_address(self) -> str: - """Return the primary ip address of the device.""" - return self.coordinator.data.ip_address - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self.config_entry.entry_id - @property def source_type(self) -> SourceType: """Return the source type which is router.""" return SourceType.ROUTER @property - def is_connected(self) -> bool: - """Return true if ping returns is_alive.""" - return self.coordinator.data.is_alive + def state(self) -> str: + """Return the state of the device.""" + if self.coordinator.data.is_alive: + return STATE_HOME + return STATE_NOT_HOME @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/ping/entity.py b/homeassistant/components/ping/entity.py new file mode 100644 index 00000000000..058d8c967e5 --- /dev/null +++ b/homeassistant/components/ping/entity.py @@ -0,0 +1,28 @@ +"""Base entity for Ping integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PingUpdateCoordinator + + +class BasePingEntity(CoordinatorEntity[PingUpdateCoordinator]): + """Representation of a Ping base entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + ) -> None: + """Initialize the Ping Binary sensor.""" + super().__init__(coordinator) + + self._attr_unique_id = config_entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data.ip_address)}, + manufacturer="Ping", + ) + + self.config_entry = config_entry diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index 2ce320d561b..f570a8afc51 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -72,7 +72,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.10_10_10_10', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -81,7 +81,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '10.10.10.10', + 'original_name': None, 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -90,6 +90,34 @@ }) # --- # name: test_setup_and_update.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ping', + '10.10.10.10', + ), + }), + 'is_new': False, + 'manufacturer': 'Ping', + 'model': None, + 'name': '10.10.10.10', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_setup_and_update.2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -106,7 +134,7 @@ 'state': 'on', }) # --- -# name: test_setup_and_update.2 +# name: test_setup_and_update.3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index b1066895e2b..5eab92b1139 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -10,7 +10,11 @@ from syrupy.filters import props from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -20,6 +24,7 @@ from tests.common import MockConfigEntry async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: @@ -29,6 +34,10 @@ async def test_setup_and_update( entry = entity_registry.async_get("binary_sensor.10_10_10_10") assert entry == snapshot(exclude=props("unique_id")) + # check the device + device = device_registry.async_get_device({(DOMAIN, "10.10.10.10")}) + assert device == snapshot + state = hass.states.get("binary_sensor.10_10_10_10") assert state == snapshot diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index b6cc6b42912..a180e8d745e 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,5 +1,9 @@ """Test the binary sensor platform of ping.""" +from datetime import timedelta +from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory +from icmplib import Host import pytest from homeassistant.components.ping.const import DOMAIN @@ -15,6 +19,7 @@ async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test sensor setup and update.""" @@ -42,6 +47,24 @@ async def test_setup_and_update( state = hass.states.get("device_tracker.10_10_10_10") assert state.state == "home" + freezer.tick(timedelta(minutes=5)) + await hass.async_block_till_done() + + # check device tracker is still "home" + state = hass.states.get("device_tracker.10_10_10_10") + assert state.state == "home" + + # check if device tracker updates to "not home" + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), + ): + freezer.tick(timedelta(minutes=5)) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.10_10_10_10") + assert state.state == "not_home" + async def test_import_issue_creation( hass: HomeAssistant, From 560ac3d0879c339ed456feb14974bb3b180e90d1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Nov 2023 11:26:21 +0100 Subject: [PATCH 702/982] Remove Wiz entity descriptions required fields mixins (#104005) --- homeassistant/components/wiz/number.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index f1212c75f25..76c4b197534 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -22,21 +22,14 @@ from .entity import WizEntity from .models import WizData -@dataclass -class WizNumberEntityDescriptionMixin: - """Mixin to describe a WiZ number entity.""" - - value_fn: Callable[[wizlight], int | None] - set_value_fn: Callable[[wizlight, int], Coroutine[None, None, None]] - required_feature: str - - -@dataclass -class WizNumberEntityDescription( - NumberEntityDescription, WizNumberEntityDescriptionMixin -): +@dataclass(kw_only=True) +class WizNumberEntityDescription(NumberEntityDescription): """Class to describe a WiZ number entity.""" + required_feature: str + set_value_fn: Callable[[wizlight, int], Coroutine[None, None, None]] + value_fn: Callable[[wizlight], int | None] + async def _async_set_speed(device: wizlight, speed: int) -> None: await device.set_speed(speed) From 3c72cd7612705b29be7fb9ffdc6e0361bfc7884b Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Fri, 24 Nov 2023 11:28:49 +0100 Subject: [PATCH 703/982] Await step in config-flow instead of moving to another form (#104412) * Await step in config-flow instead of moving to another form * Fix call to step-api-token * Fix condition in step-api-token --- .../pvpc_hourly_pricing/config_flow.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index a98b9faf56e..66092cb9211 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -77,13 +77,7 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._power = user_input[ATTR_POWER] self._power_p3 = user_input[ATTR_POWER_P3] self._use_api_token = user_input[CONF_USE_API_TOKEN] - return self.async_show_form( - step_id="api_token", - data_schema=vol.Schema( - {vol.Required(CONF_API_TOKEN, default=self._api_token): str} - ), - description_placeholders={"mail_to_link": _MAIL_TO_LINK}, - ) + return await self.async_step_api_token() data_schema = vol.Schema( { @@ -96,14 +90,24 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="user", data_schema=data_schema) - async def async_step_api_token(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_api_token( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle optional step to define API token for extra sensors.""" - self._api_token = user_input[CONF_API_TOKEN] - return await self._async_verify( - "api_token", + if user_input is not None: + self._api_token = user_input[CONF_API_TOKEN] + return await self._async_verify( + "api_token", + data_schema=vol.Schema( + {vol.Required(CONF_API_TOKEN, default=self._api_token): str} + ), + ) + return self.async_show_form( + step_id="api_token", data_schema=vol.Schema( {vol.Required(CONF_API_TOKEN, default=self._api_token): str} ), + description_placeholders={"mail_to_link": _MAIL_TO_LINK}, ) async def _async_verify(self, step_id: str, data_schema: vol.Schema) -> FlowResult: From b41b56e54c3ae0a06e42b7c370f0dbbd625c15f6 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 24 Nov 2023 11:39:13 +0100 Subject: [PATCH 704/982] Support new deCONZ Particulate Matter endpoint (#104276) --- homeassistant/components/deconz/manifest.json | 2 +- homeassistant/components/deconz/sensor.py | 13 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/deconz/test_sensor.py | 49 +++++++++++++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 6245558a1c5..af1824e441c 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==113"], + "requirements": ["pydeconz==114"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index a635a784676..ecb9ac9b297 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -17,6 +17,7 @@ from pydeconz.models.sensor.generic_status import GenericStatus from pydeconz.models.sensor.humidity import Humidity from pydeconz.models.sensor.light_level import LightLevel from pydeconz.models.sensor.moisture import Moisture +from pydeconz.models.sensor.particulate_matter import ParticulateMatter from pydeconz.models.sensor.power import Power from pydeconz.models.sensor.pressure import Pressure from pydeconz.models.sensor.switch import Switch @@ -83,6 +84,7 @@ T = TypeVar( Humidity, LightLevel, Moisture, + ParticulateMatter, Power, Pressure, Temperature, @@ -213,6 +215,17 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, suggested_display_precision=1, ), + DeconzSensorDescription[ParticulateMatter]( + key="particulate_matter_pm2_5", + supported_fn=lambda device: device.measured_value is not None, + update_key="measured_value", + value_fn=lambda device: device.measured_value, + instance_check=ParticulateMatter, + name_suffix="PM25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), DeconzSensorDescription[Power]( key="power", supported_fn=lambda device: device.power is not None, diff --git a/requirements_all.txt b/requirements_all.txt index fd0c3225958..d35ee88e994 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1680,7 +1680,7 @@ pydaikin==2.11.1 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==113 +pydeconz==114 # homeassistant.components.delijn pydelijn==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f440046b5a6..2ad933fa42c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1272,7 +1272,7 @@ pycsspeechtts==1.0.8 pydaikin==2.11.1 # homeassistant.components.deconz -pydeconz==113 +pydeconz==114 # homeassistant.components.dexcom pydexcom==0.2.3 diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 7fa93266aef..38d68d135b6 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -530,6 +530,55 @@ TEST_DATA = [ "next_state": "1.3", }, ), + ( # Particulate matter -> pm2_5 + { + "capabilities": { + "measured_value": { + "max": 999, + "min": 0, + "quantity": "density", + "substance": "PM2.5", + "unit": "ug/m^3", + } + }, + "config": {"on": True, "reachable": True}, + "ep": 1, + "etag": "2a67a4b5cbcc20532c0ee75e2abac0c3", + "lastannounced": None, + "lastseen": "2023-10-29T12:59Z", + "manufacturername": "IKEA of Sweden", + "modelid": "STARKVIND Air purifier table", + "name": "STARKVIND AirPurifier", + "productid": "E2006", + "state": { + "airquality": "excellent", + "lastupdated": "2023-10-29T12:59:27.976", + "measured_value": 1, + "pm2_5": 1, + }, + "swversion": "1.1.001", + "type": "ZHAParticulateMatter", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-042a", + }, + { + "entity_count": 1, + "device_count": 3, + "entity_id": "sensor.starkvind_airpurifier_pm25", + "unique_id": "xx:xx:xx:xx:xx:xx:xx:xx-01-042a-particulate_matter_pm2_5", + "state": "1", + "entity_category": None, + "device_class": SensorDeviceClass.PM25, + "state_class": SensorStateClass.MEASUREMENT, + "attributes": { + "friendly_name": "STARKVIND AirPurifier PM25", + "device_class": SensorDeviceClass.PM25, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + "websocket_event": {"state": {"measured_value": 2}}, + "next_state": "2", + }, + ), ( # Power sensor { "config": { From 9ed745638df5e2ae44d5d405a897690518fb6950 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Nov 2023 11:46:02 +0100 Subject: [PATCH 705/982] Chunk purging attributes and data ids for old SQLite versions (#104296) --- homeassistant/components/recorder/purge.py | 61 ++++++++++++++-------- homeassistant/components/recorder/util.py | 16 +++++- tests/components/recorder/test_util.py | 22 ++++++++ 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 8bc6584c5a1..8dd539f84f3 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -41,7 +41,7 @@ from .queries import ( find_statistics_runs_to_purge, ) from .repack import repack_database -from .util import chunked, retryable_database_job, session_scope +from .util import chunked_or_all, retryable_database_job, session_scope if TYPE_CHECKING: from . import Recorder @@ -283,12 +283,16 @@ def _select_event_data_ids_to_purge( def _select_unused_attributes_ids( - session: Session, attributes_ids: set[int], database_engine: DatabaseEngine + instance: Recorder, + session: Session, + attributes_ids: set[int], + database_engine: DatabaseEngine, ) -> set[int]: """Return a set of attributes ids that are not used by any states in the db.""" if not attributes_ids: return set() + seen_ids: set[int] = set() if not database_engine.optimizer.slow_range_in_select: # # SQLite has a superior query optimizer for the distinct query below as it uses @@ -303,12 +307,17 @@ def _select_unused_attributes_ids( # (136723); # ...Using index # - seen_ids = { - state[0] - for state in session.execute( - attributes_ids_exist_in_states_with_fast_in_distinct(attributes_ids) - ).all() - } + for attributes_ids_chunk in chunked_or_all( + attributes_ids, instance.max_bind_vars + ): + seen_ids.update( + state[0] + for state in session.execute( + attributes_ids_exist_in_states_with_fast_in_distinct( + attributes_ids_chunk + ) + ).all() + ) else: # # This branch is for DBMS that cannot optimize the distinct query well and has @@ -334,7 +343,6 @@ def _select_unused_attributes_ids( # We now break the query into groups of 100 and use a lambda_stmt to ensure # that the query is only cached once. # - seen_ids = set() groups = [iter(attributes_ids)] * 100 for attr_ids in zip_longest(*groups, fillvalue=None): seen_ids |= { @@ -361,29 +369,33 @@ def _purge_unused_attributes_ids( database_engine = instance.database_engine assert database_engine is not None if unused_attribute_ids_set := _select_unused_attributes_ids( - session, attributes_ids_batch, database_engine + instance, session, attributes_ids_batch, database_engine ): _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) def _select_unused_event_data_ids( - session: Session, data_ids: set[int], database_engine: DatabaseEngine + instance: Recorder, + session: Session, + data_ids: set[int], + database_engine: DatabaseEngine, ) -> set[int]: """Return a set of event data ids that are not used by any events in the db.""" if not data_ids: return set() + seen_ids: set[int] = set() # See _select_unused_attributes_ids for why this function # branches for non-sqlite databases. if not database_engine.optimizer.slow_range_in_select: - seen_ids = { - state[0] - for state in session.execute( - data_ids_exist_in_events_with_fast_in_distinct(data_ids) - ).all() - } + for data_ids_chunk in chunked_or_all(data_ids, instance.max_bind_vars): + seen_ids.update( + state[0] + for state in session.execute( + data_ids_exist_in_events_with_fast_in_distinct(data_ids_chunk) + ).all() + ) else: - seen_ids = set() groups = [iter(data_ids)] * 100 for data_ids_group in zip_longest(*groups, fillvalue=None): seen_ids |= { @@ -404,7 +416,7 @@ def _purge_unused_data_ids( database_engine = instance.database_engine assert database_engine is not None if unused_data_ids_set := _select_unused_event_data_ids( - session, data_ids_batch, database_engine + instance, session, data_ids_batch, database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) @@ -519,7 +531,7 @@ def _purge_batch_attributes_ids( instance: Recorder, session: Session, attributes_ids: set[int] ) -> None: """Delete old attributes ids in batches of max_bind_vars.""" - for attributes_ids_chunk in chunked(attributes_ids, instance.max_bind_vars): + for attributes_ids_chunk in chunked_or_all(attributes_ids, instance.max_bind_vars): deleted_rows = session.execute( delete_states_attributes_rows(attributes_ids_chunk) ) @@ -533,7 +545,7 @@ def _purge_batch_data_ids( instance: Recorder, session: Session, data_ids: set[int] ) -> None: """Delete old event data ids in batches of max_bind_vars.""" - for data_ids_chunk in chunked(data_ids, instance.max_bind_vars): + for data_ids_chunk in chunked_or_all(data_ids, instance.max_bind_vars): deleted_rows = session.execute(delete_event_data_rows(data_ids_chunk)) _LOGGER.debug("Deleted %s data events", deleted_rows) @@ -694,7 +706,10 @@ def _purge_filtered_states( # we will need to purge them here. _purge_event_ids(session, filtered_event_ids) unused_attribute_ids_set = _select_unused_attributes_ids( - session, {id_ for id_ in attributes_ids if id_ is not None}, database_engine + instance, + session, + {id_ for id_ in attributes_ids if id_ is not None}, + database_engine, ) _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) return False @@ -741,7 +756,7 @@ def _purge_filtered_events( _purge_state_ids(instance, session, state_ids) _purge_event_ids(session, event_ids_set) if unused_data_ids_set := _select_unused_event_data_ids( - session, set(data_ids), database_engine + instance, session, set(data_ids), database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) return False diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index f94601bb2cb..2d518d8874b 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,7 +1,7 @@ """SQLAlchemy util functions.""" from __future__ import annotations -from collections.abc import Callable, Generator, Iterable, Sequence +from collections.abc import Callable, Collection, Generator, Iterable, Sequence from contextlib import contextmanager from datetime import date, datetime, timedelta import functools @@ -857,6 +857,20 @@ def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: return iter(partial(take, chunked_num, iter(iterable)), []) +def chunked_or_all(iterable: Collection[Any], chunked_num: int) -> Iterable[Any]: + """Break *collection* into iterables of length *n*. + + Returns the collection if its length is less than *n*. + + Unlike chunked, this function requires a collection so it can + determine the length of the collection and return the collection + if it is less than *n*. + """ + if len(iterable) <= chunked_num: + return (iterable,) + return chunked(iterable, chunked_num) + + def get_index_by_name(session: Session, table_name: str, index_name: str) -> str | None: """Get an index by name.""" connection = session.connection() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index a7b15a7f12d..0a30895adc9 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -25,6 +25,7 @@ from homeassistant.components.recorder.models import ( process_timestamp, ) from homeassistant.components.recorder.util import ( + chunked_or_all, end_incomplete_runs, is_second_sunday, resolve_period, @@ -1023,3 +1024,24 @@ async def test_resolve_period(hass: HomeAssistant) -> None: } } ) == (now - timedelta(hours=1, minutes=25), now - timedelta(minutes=25)) + + +def test_chunked_or_all(): + """Test chunked_or_all can iterate chunk sizes larger than the passed in collection.""" + all = [] + incoming = (1, 2, 3, 4) + for chunk in chunked_or_all(incoming, 2): + assert len(chunk) == 2 + all.extend(chunk) + assert all == [1, 2, 3, 4] + + all = [] + incoming = (1, 2, 3, 4) + for chunk in chunked_or_all(incoming, 5): + assert len(chunk) == 4 + # Verify the chunk is the same object as the incoming + # collection since we want to avoid copying the collection + # if we don't need to + assert chunk is incoming + all.extend(chunk) + assert all == [1, 2, 3, 4] From e9dd158a8d316eee3fe96da384ee0f6027924ae7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 24 Nov 2023 11:50:16 +0100 Subject: [PATCH 706/982] Reolink ptz service to specify move speed (#104350) --- homeassistant/components/reolink/button.py | 42 ++++++++++++++++++- .../components/reolink/services.yaml | 18 ++++++++ homeassistant/components/reolink/strings.json | 12 ++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/reolink/services.yaml diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 8d9f1e55581..6e9c9c2e386 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -7,22 +7,31 @@ from typing import Any from reolink_aio.api import GuardEnum, Host, PtzEnum from reolink_aio.exceptions import ReolinkError +import voluptuous as vol from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, ) +from homeassistant.components.camera import CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from . import ReolinkData from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +ATTR_SPEED = "speed" +SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM + @dataclass(kw_only=True) class ReolinkButtonEntityDescription( @@ -33,6 +42,7 @@ class ReolinkButtonEntityDescription( enabled_default: Callable[[Host, int], bool] | None = None method: Callable[[Host, int], Any] supported: Callable[[Host, int], bool] = lambda api, ch: True + ptz_cmd: str | None = None @dataclass(kw_only=True) @@ -60,6 +70,7 @@ BUTTON_ENTITIES = ( icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value), + ptz_cmd=PtzEnum.left.value, ), ReolinkButtonEntityDescription( key="ptz_right", @@ -67,6 +78,7 @@ BUTTON_ENTITIES = ( icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value), + ptz_cmd=PtzEnum.right.value, ), ReolinkButtonEntityDescription( key="ptz_up", @@ -74,6 +86,7 @@ BUTTON_ENTITIES = ( icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value), + ptz_cmd=PtzEnum.up.value, ), ReolinkButtonEntityDescription( key="ptz_down", @@ -81,6 +94,7 @@ BUTTON_ENTITIES = ( icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value), + ptz_cmd=PtzEnum.down.value, ), ReolinkButtonEntityDescription( key="ptz_zoom_in", @@ -89,6 +103,7 @@ BUTTON_ENTITIES = ( entity_registry_enabled_default=False, supported=lambda api, ch: api.supported(ch, "zoom_basic"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomin.value), + ptz_cmd=PtzEnum.zoomin.value, ), ReolinkButtonEntityDescription( key="ptz_zoom_out", @@ -97,6 +112,7 @@ BUTTON_ENTITIES = ( entity_registry_enabled_default=False, supported=lambda api, ch: api.supported(ch, "zoom_basic"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomout.value), + ptz_cmd=PtzEnum.zoomout.value, ), ReolinkButtonEntityDescription( key="ptz_calibrate", @@ -158,6 +174,14 @@ async def async_setup_entry( ) async_add_entities(entities) + platform = async_get_current_platform() + platform.async_register_entity_service( + "ptz_move", + {vol.Required(ATTR_SPEED): cv.positive_int}, + "async_ptz_move", + [SUPPORT_PTZ_SPEED], + ) + class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): """Base button entity class for Reolink IP cameras.""" @@ -182,6 +206,12 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): entity_description.enabled_default(self._host.api, self._channel) ) + if ( + self._host.api.supported(channel, "ptz_speed") + and entity_description.ptz_cmd is not None + ): + self._attr_supported_features = SUPPORT_PTZ_SPEED + async def async_press(self) -> None: """Execute the button action.""" try: @@ -189,6 +219,16 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): except ReolinkError as err: raise HomeAssistantError(err) from err + async def async_ptz_move(self, **kwargs) -> None: + """PTZ move with speed.""" + speed = kwargs[ATTR_SPEED] + try: + await self._host.api.set_ptz_command( + self._channel, command=self.entity_description.ptz_cmd, speed=speed + ) + except ReolinkError as err: + raise HomeAssistantError(err) from err + class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): """Base button entity class for Reolink IP cameras.""" diff --git a/homeassistant/components/reolink/services.yaml b/homeassistant/components/reolink/services.yaml new file mode 100644 index 00000000000..42b9af34eb0 --- /dev/null +++ b/homeassistant/components/reolink/services.yaml @@ -0,0 +1,18 @@ +# Describes the format for available reolink services + +ptz_move: + target: + entity: + integration: reolink + domain: button + supported_features: + - camera.CameraEntityFeature.STREAM + fields: + speed: + required: true + default: 10 + selector: + number: + min: 1 + max: 64 + step: 1 diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index d81e25e9887..5b26d70b657 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -61,6 +61,18 @@ "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The latest firmware can be downloaded from the [Reolink download center]({download_link})." } }, + "services": { + "ptz_move": { + "name": "PTZ move", + "description": "Move the camera with a specific speed.", + "fields": { + "speed": { + "name": "Speed", + "description": "PTZ move speed." + } + } + } + }, "entity": { "binary_sensor": { "face": { From 76427a00806197099fc5c15fceb653ea29367036 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 24 Nov 2023 05:54:43 -0500 Subject: [PATCH 707/982] Deprecate Harmony switch platform (#92787) * Deprecate Harmony switches * uno mas * add test for issues * switch to remote * uno mas --- homeassistant/components/harmony/strings.json | 10 +++ homeassistant/components/harmony/switch.py | 32 +++++++++- tests/components/harmony/test_switch.py | 64 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index 9ae22090d7f..c9c7a559758 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -42,6 +42,16 @@ } } }, + "issues": { + "deprecated_switches": { + "title": "The Logitech Harmony switch platform is being removed", + "description": "Using the switch platform to change the current activity is now deprecated and will be removed in a future version of Home Assistant.\n\nPlease adjust any automations or scripts that use switch entities to instead use the select entity." + }, + "deprecated_switches_entity": { + "title": "Deprecated Harmony entity detected in {info}", + "description": "Your Harmony entity `{entity}` is being used in `{info}`. A select entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." + } + }, "services": { "sync": { "name": "Sync", diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index acd04596bd5..a3c588c06bb 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -1,12 +1,15 @@ """Support for Harmony Hub activities.""" import logging -from typing import Any +from typing import Any, cast -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, HARMONY_DATA from .data import HarmonyData @@ -20,6 +23,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activity switches.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] activities = data.activities @@ -72,6 +84,22 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): ) ) ) + entity_automations = automations_with_entity(self.hass, self.entity_id) + entity_scripts = scripts_with_entity(self.hass, self.entity_id) + for item in entity_automations + entity_scripts: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_switches_{self.entity_id}_{item}", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches_entity", + translation_placeholders={ + "entity": f"{SWITCH_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "info": item, + }, + ) @callback def _async_activity_update(self, activity_info: tuple): diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index 58cbd3eac56..59e5a7c7fc8 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -1,7 +1,10 @@ """Test the Logitech Harmony Hub activity switches.""" from datetime import timedelta +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity from homeassistant.components.harmony.const import DOMAIN +from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -17,6 +20,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component from homeassistant.util import utcnow from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME @@ -133,3 +138,62 @@ async def _toggle_switch_and_wait(hass, service_name, entity): blocking=True, ) await hass.async_block_till_done() + + +async def test_create_issue( + harmony_client, + mock_hc, + hass: HomeAssistant, + mock_write_config, + entity_registry_enabled_by_default: None, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": ENTITY_WATCH_TV}, + "action": {"service": "switch.turn_on", "entity_id": ENTITY_WATCH_TV}, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "service": "switch.turn_on", + "data": {"entity_id": ENTITY_WATCH_TV}, + }, + ], + } + } + }, + ) + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert automations_with_entity(hass, ENTITY_WATCH_TV)[0] == "automation.test" + assert scripts_with_entity(hass, ENTITY_WATCH_TV)[0] == "script.test" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_switches") + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_switches_switch.guest_room_watch_tv_automation.test" + ) + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_switches_switch.guest_room_watch_tv_script.test" + ) + + assert len(issue_registry.issues) == 3 From 4096687112dc0dc833f403d03f361f474270491d Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Fri, 24 Nov 2023 06:33:51 -0500 Subject: [PATCH 708/982] Allow for manual config entry of Insteon PLM path (#103705) --- .../components/insteon/config_flow.py | 22 ++++++++ homeassistant/components/insteon/schemas.py | 5 ++ tests/components/insteon/const.py | 4 ++ tests/components/insteon/test_config_flow.py | 53 ++++++++++++++++++- 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index f5bafd935a0..36e977f6db0 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -38,6 +38,7 @@ from .schemas import ( add_x10_device, build_device_override_schema, build_hub_schema, + build_plm_manual_schema, build_plm_schema, build_remove_override_schema, build_remove_x10_schema, @@ -46,6 +47,7 @@ from .schemas import ( from .utils import async_get_usb_ports STEP_PLM = "plm" +STEP_PLM_MANUALLY = "plm_manually" STEP_HUB_V1 = "hubv1" STEP_HUB_V2 = "hubv2" STEP_CHANGE_HUB_CONFIG = "change_hub_config" @@ -55,6 +57,7 @@ STEP_ADD_OVERRIDE = "add_override" STEP_REMOVE_OVERRIDE = "remove_override" STEP_REMOVE_X10 = "remove_x10" MODEM_TYPE = "modem_type" +PLM_MANUAL = "manual" _LOGGER = logging.getLogger(__name__) @@ -129,16 +132,35 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Set up the PLM modem type.""" errors = {} if user_input is not None: + if user_input[CONF_DEVICE] == PLM_MANUAL: + return await self.async_step_plm_manually() if await _async_connect(**user_input): return self.async_create_entry(title="", data=user_input) errors["base"] = "cannot_connect" schema_defaults = user_input if user_input is not None else {} ports = await async_get_usb_ports(self.hass) + if not ports: + return await self.async_step_plm_manually() + ports[PLM_MANUAL] = "Enter manually" data_schema = build_plm_schema(ports, **schema_defaults) return self.async_show_form( step_id=STEP_PLM, data_schema=data_schema, errors=errors ) + async def async_step_plm_manually(self, user_input=None): + """Set up the PLM modem type manually.""" + errors = {} + schema_defaults = {} + if user_input is not None: + if await _async_connect(**user_input): + return self.async_create_entry(title="", data=user_input) + errors["base"] = "cannot_connect" + schema_defaults = user_input + data_schema = build_plm_manual_schema(**schema_defaults) + return self.async_show_form( + step_id=STEP_PLM_MANUALLY, data_schema=data_schema, errors=errors + ) + async def async_step_hubv1(self, user_input=None): """Set up the Hub v1 modem type.""" return await self._async_setup_hub(hub_version=1, user_input=user_input) diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index e6b22a8cbb9..497af743195 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -195,6 +195,11 @@ def build_plm_schema(ports: dict[str, str], device=vol.UNDEFINED): return vol.Schema({vol.Required(CONF_DEVICE, default=device): vol.In(ports)}) +def build_plm_manual_schema(device=vol.UNDEFINED): + """Build the manual PLM schema for config flow.""" + return vol.Schema({vol.Required(CONF_DEVICE, default=device): str}) + + def build_hub_schema( hub_version, host=vol.UNDEFINED, diff --git a/tests/components/insteon/const.py b/tests/components/insteon/const.py index e731c51d6c6..53db12acb04 100644 --- a/tests/components/insteon/const.py +++ b/tests/components/insteon/const.py @@ -38,6 +38,10 @@ MOCK_USER_INPUT_PLM = { CONF_DEVICE: MOCK_DEVICE, } +MOCK_USER_INPUT_PLM_MANUAL = { + CONF_DEVICE: "manual", +} + MOCK_USER_INPUT_HUB_V2 = { CONF_HOST: MOCK_HOSTNAME, CONF_USERNAME: MOCK_USERNAME, diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index e15b7b2a287..106c93071be 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -1,5 +1,4 @@ """Test the config flow for the Insteon integration.""" - from unittest.mock import patch import pytest @@ -15,6 +14,7 @@ from homeassistant.components.insteon.config_flow import ( STEP_HUB_V1, STEP_HUB_V2, STEP_PLM, + STEP_PLM_MANUALLY, STEP_REMOVE_OVERRIDE, STEP_REMOVE_X10, ) @@ -45,6 +45,7 @@ from .const import ( MOCK_USER_INPUT_HUB_V1, MOCK_USER_INPUT_HUB_V2, MOCK_USER_INPUT_PLM, + MOCK_USER_INPUT_PLM_MANUAL, PATCH_ASYNC_SETUP, PATCH_ASYNC_SETUP_ENTRY, PATCH_CONNECTION, @@ -155,6 +156,41 @@ async def test_form_select_plm(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_select_plm_no_usb(hass: HomeAssistant) -> None: + """Test we set up the PLM when no comm ports are found.""" + + temp_usb_list = dict(USB_PORTS) + USB_PORTS.clear() + result = await _init_form(hass, STEP_PLM) + + result2, _, _ = await _device_form( + hass, result["flow_id"], mock_successful_connection, None + ) + USB_PORTS.update(temp_usb_list) + assert result2["type"] == "form" + assert result2["step_id"] == STEP_PLM_MANUALLY + + +async def test_form_select_plm_manual(hass: HomeAssistant) -> None: + """Test we set up the PLM correctly.""" + + result = await _init_form(hass, STEP_PLM) + + result2, mock_setup, mock_setup_entry = await _device_form( + hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_PLM_MANUAL + ) + + result3, mock_setup, mock_setup_entry = await _device_form( + hass, result2["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM + ) + assert result2["type"] == "form" + assert result3["type"] == "create_entry" + assert result3["data"] == MOCK_USER_INPUT_PLM + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_form_select_hub_v1(hass: HomeAssistant) -> None: """Test we set up the Hub v1 correctly.""" @@ -225,6 +261,21 @@ async def test_failed_connection_plm(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_failed_connection_plm_manually(hass: HomeAssistant) -> None: + """Test a failed connection with the PLM.""" + + result = await _init_form(hass, STEP_PLM) + + result2, _, _ = await _device_form( + hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM_MANUAL + ) + result3, _, _ = await _device_form( + hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_PLM + ) + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} + + async def test_failed_connection_hub(hass: HomeAssistant) -> None: """Test a failed connection with a Hub.""" From 378a708bf7967edcc0c7e5226dfbc3a33a87d2b9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 24 Nov 2023 03:54:49 -0800 Subject: [PATCH 709/982] Only show Google Tasks that are parents and fix ordering (#103820) --- homeassistant/components/google_tasks/todo.py | 17 ++- .../google_tasks/snapshots/test_todo.ambr | 24 ++++ tests/components/google_tasks/test_todo.py | 113 ++++++++++++++++-- 3 files changed, 145 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 01ceb0349e6..e5c90523a18 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from typing import cast +from typing import Any, cast from homeassistant.components.todo import ( TodoItem, @@ -96,7 +96,7 @@ class GoogleTaskTodoListEntity( item.get("status"), TodoItemStatus.NEEDS_ACTION # type: ignore[arg-type] ), ) - for item in self.coordinator.data + for item in _order_tasks(self.coordinator.data) ] async def async_create_todo_item(self, item: TodoItem) -> None: @@ -121,3 +121,16 @@ class GoogleTaskTodoListEntity( """Delete To-do items.""" await self.coordinator.api.delete(self._task_list_id, uids) await self.coordinator.async_refresh() + + +def _order_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Order the task items response. + + All tasks have an order amongst their sibblings based on position. + + Home Assistant To-do items do not support the Google Task parent/sibbling + relationships and the desired behavior is for them to be filtered. + """ + parents = [task for task in tasks if task.get("parent") is None] + parents.sort(key=lambda task: task["position"]) + return parents diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 98b59b7697b..73289b313d9 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -14,6 +14,30 @@ 'POST', ) # --- +# name: test_parent_child_ordering[api_responses0] + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Task 1', + 'uid': 'task-1', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Task 2', + 'uid': 'task-2', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Task 3 (Parent)', + 'uid': 'task-3', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Task 4', + 'uid': 'task-4', + }), + ]) +# --- # name: test_partial_update_status[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 70309e64222..8b0b49ee109 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -45,14 +45,34 @@ BOUNDARY = "batch_00972cc8-75bd-11ee-9692-0242ac110002" # Arbitrary uuid LIST_TASKS_RESPONSE_WATER = { "items": [ - {"id": "some-task-id", "title": "Water", "status": "needsAction"}, + { + "id": "some-task-id", + "title": "Water", + "status": "needsAction", + "position": "00000000000000000001", + }, ], } LIST_TASKS_RESPONSE_MULTIPLE = { "items": [ - {"id": "some-task-id-1", "title": "Water", "status": "needsAction"}, - {"id": "some-task-id-2", "title": "Milk", "status": "needsAction"}, - {"id": "some-task-id-3", "title": "Cheese", "status": "needsAction"}, + { + "id": "some-task-id-2", + "title": "Milk", + "status": "needsAction", + "position": "00000000000000000002", + }, + { + "id": "some-task-id-1", + "title": "Water", + "status": "needsAction", + "position": "00000000000000000001", + }, + { + "id": "some-task-id-3", + "title": "Cheese", + "status": "needsAction", + "position": "00000000000000000003", + }, ], } @@ -182,8 +202,18 @@ def mock_http_response(response_handler: list | Callable) -> Mock: LIST_TASK_LIST_RESPONSE, { "items": [ - {"id": "task-1", "title": "Task 1", "status": "needsAction"}, - {"id": "task-2", "title": "Task 2", "status": "completed"}, + { + "id": "task-1", + "title": "Task 1", + "status": "needsAction", + "position": "0000000000000001", + }, + { + "id": "task-2", + "title": "Task 2", + "status": "completed", + "position": "0000000000000002", + }, ], }, ] @@ -541,7 +571,7 @@ async def test_partial_update_status( LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_MULTIPLE, [EMPTY_RESPONSE, EMPTY_RESPONSE, EMPTY_RESPONSE], # Delete batch - LIST_TASKS_RESPONSE, # refresh after create + LIST_TASKS_RESPONSE, # refresh after delete ] ) ) @@ -697,3 +727,72 @@ async def test_delete_server_error( target={"entity_id": "todo.my_tasks"}, blocking=True, ) + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + { + "items": [ + { + "id": "task-3-2", + "title": "Child 2", + "status": "needsAction", + "parent": "task-3", + "position": "0000000000000002", + }, + { + "id": "task-3", + "title": "Task 3 (Parent)", + "status": "needsAction", + "position": "0000000000000003", + }, + { + "id": "task-2", + "title": "Task 2", + "status": "needsAction", + "position": "0000000000000002", + }, + { + "id": "task-1", + "title": "Task 1", + "status": "needsAction", + "position": "0000000000000001", + }, + { + "id": "task-3-1", + "title": "Child 1", + "status": "needsAction", + "parent": "task-3", + "position": "0000000000000001", + }, + { + "id": "task-4", + "title": "Task 4", + "status": "needsAction", + "position": "0000000000000004", + }, + ], + }, + ] + ], +) +async def test_parent_child_ordering( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + snapshot: SnapshotAssertion, +) -> None: + """Test getting todo list items.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "4" + + items = await ws_get_items() + assert items == snapshot From 130822fcc6d8426def5a6e6e56f95c99bc0b13a5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 24 Nov 2023 06:55:05 -0500 Subject: [PATCH 710/982] Attach Matter info to Google Assistant serialize (#103768) --- .../components/google_assistant/helpers.py | 12 ++++- .../components/google_assistant/manifest.json | 2 +- .../google_assistant/test_helpers.py | 53 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index b2cda5522ee..2eeb1903c85 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -15,7 +15,7 @@ from aiohttp.web import json_response from awesomeversion import AwesomeVersion from yarl import URL -from homeassistant.components import webhook +from homeassistant.components import matter, webhook from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, @@ -678,10 +678,18 @@ class GoogleEntity: elif area_entry and area_entry.name: device["roomHint"] = area_entry.name - # Add deviceInfo if not device_entry: return device + # Add Matter info + if "matter" in self.hass.config.components and ( + matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + ): + device["matterUniqueId"] = matter_info["unique_id"] + device["matterOriginalVendorId"] = matter_info["vendor_id"] + device["matterOriginalProductId"] = matter_info["product_id"] + + # Add deviceInfo device_info = {} if device_entry.manufacturer: diff --git a/homeassistant/components/google_assistant/manifest.json b/homeassistant/components/google_assistant/manifest.json index 3c7ac043441..e36f6a1ca87 100644 --- a/homeassistant/components/google_assistant/manifest.json +++ b/homeassistant/components/google_assistant/manifest.json @@ -1,7 +1,7 @@ { "domain": "google_assistant", "name": "Google Assistant", - "after_dependencies": ["camera"], + "after_dependencies": ["camera", "matter"], "codeowners": ["@home-assistant/cloud"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/google_assistant", diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 57915968933..771df137278 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -14,14 +14,17 @@ from homeassistant.components.google_assistant.const import ( SOURCE_LOCAL, STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) +from homeassistant.components.matter.models import MatterDeviceInfo from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import MockConfig from tests.common import ( + MockConfigEntry, async_capture_events, async_fire_time_changed, async_mock_service, @@ -73,6 +76,56 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass: HomeAssistant) assert "customData" not in serialized +async def test_google_entity_sync_serialize_with_matter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sync serialize attributes of a GoogleEntity that is also a Matter device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + manufacturer="Someone", + model="Some model", + sw_version="Some Version", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity = entity_registry.async_get_or_create( + "light", + "test", + "1235", + suggested_object_id="ceiling_lights", + device_id=device.id, + ) + hass.states.async_set("light.ceiling_lights", "off") + + entity = helpers.GoogleEntity( + hass, MockConfig(hass=hass), hass.states.get("light.ceiling_lights") + ) + + serialized = entity.sync_serialize(None, "mock-uuid") + assert "matterUniqueId" not in serialized + assert "matterOriginalVendorId" not in serialized + assert "matterOriginalProductId" not in serialized + + hass.config.components.add("matter") + + with patch( + "homeassistant.components.matter.get_matter_device_info", + return_value=MatterDeviceInfo( + unique_id="mock-unique-id", + vendor_id="mock-vendor-id", + product_id="mock-product-id", + ), + ): + serialized = entity.sync_serialize("mock-user-id", "abcdef") + + assert serialized["matterUniqueId"] == "mock-unique-id" + assert serialized["matterOriginalVendorId"] == "mock-vendor-id" + assert serialized["matterOriginalProductId"] == "mock-product-id" + + async def test_config_local_sdk( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: From adc56b6b67ba786a3f7f62d9a2851cfd60986113 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 24 Nov 2023 12:55:41 +0100 Subject: [PATCH 711/982] Add support for Shelly Wall Display in thermostat mode (#103937) --- homeassistant/components/shelly/__init__.py | 1 + homeassistant/components/shelly/climate.py | 102 +++++++++++++++++++- homeassistant/components/shelly/const.py | 5 + homeassistant/components/shelly/switch.py | 10 +- tests/components/shelly/conftest.py | 9 ++ tests/components/shelly/test_climate.py | 101 ++++++++++++++++++- tests/components/shelly/test_switch.py | 39 +++++++- 7 files changed, 262 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 5efc5c849d7..b29fdcc6d19 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -73,6 +73,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [ RPC_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.EVENT, Platform.LIGHT, diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 35c18511860..dbc4960af58 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -37,9 +37,12 @@ from .const import ( DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, + RPC_THERMOSTAT_SETTINGS, SHTRV_01_TEMPERATURE_SETTINGS, ) -from .coordinator import ShellyBlockCoordinator, get_entry_data +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data +from .entity import ShellyRpcEntity +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -48,6 +51,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up climate device.""" + if get_device_entry_gen(config_entry) == 2: + return async_setup_rpc_entry(hass, config_entry, async_add_entities) + coordinator = get_entry_data(hass)[config_entry.entry_id].block assert coordinator if coordinator.device.initialized: @@ -105,6 +111,29 @@ def async_restore_climate_entities( break +@callback +def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat") + + climate_ids = [] + for id_ in climate_key_ids: + climate_ids.append(id_) + unique_id = f"{coordinator.mac}-switch:{id_}" + async_remove_shelly_entity(hass, "switch", unique_id) + + if not climate_ids: + return + + async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) + + @dataclass class ShellyClimateExtraStoredData(ExtraStoredData): """Object to hold extra stored data.""" @@ -381,3 +410,74 @@ class BlockSleepingClimate( self.coordinator.entry.async_start_reauth(self.hass) else: self.async_write_ha_state() + + +class RpcClimate(ShellyRpcEntity, ClimateEntity): + """Entity that controls a thermostat on RPC based Shelly devices.""" + + _attr_hvac_modes = [HVACMode.OFF] + _attr_icon = "mdi:thermostat" + _attr_max_temp = RPC_THERMOSTAT_SETTINGS["max"] + _attr_min_temp = RPC_THERMOSTAT_SETTINGS["min"] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + """Initialize.""" + super().__init__(coordinator, f"thermostat:{id_}") + self._id = id_ + self._thermostat_type = coordinator.device.config[f"thermostat:{id_}"].get( + "type", "heating" + ) + if self._thermostat_type == "cooling": + self._attr_hvac_modes.append(HVACMode.COOL) + else: + self._attr_hvac_modes.append(HVACMode.HEAT) + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + return cast(float, self.status["target_C"]) + + @property + def current_temperature(self) -> float | None: + """Return current temperature.""" + return cast(float, self.status["current_C"]) + + @property + def hvac_mode(self) -> HVACMode: + """HVAC current mode.""" + if not self.status["enable"]: + return HVACMode.OFF + + return HVACMode.COOL if self._thermostat_type == "cooling" else HVACMode.HEAT + + @property + def hvac_action(self) -> HVACAction: + """HVAC current action.""" + if not self.status["output"]: + return HVACAction.IDLE + + return ( + HVACAction.COOLING + if self._thermostat_type == "cooling" + else HVACAction.HEATING + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + await self.call_rpc( + "Thermostat.SetConfig", + {"config": {"id": self._id, "target_C": target_temp}}, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + mode = hvac_mode in (HVACMode.COOL, HVACMode.HEAT) + await self.call_rpc( + "Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}} + ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 95ffa2de91e..db7623f684e 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -149,6 +149,11 @@ SHTRV_01_TEMPERATURE_SETTINGS: Final = { "step": 0.5, "default": 20.0, } +RPC_THERMOSTAT_SETTINGS: Final = { + "min": 5, + "max": 35, + "step": 0.5, +} # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 395b386993a..5610956e790 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import GAS_VALVE_OPEN_STATES +from .const import GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -116,6 +116,14 @@ def async_setup_rpc_entry( if is_rpc_channel_type_light(coordinator.device.config, id_): continue + if coordinator.model == MODEL_WALL_DISPLAY: + if coordinator.device.shelly["relay_operational"]: + # Wall Display in relay mode, we need to remove a climate entity + unique_id = f"{coordinator.mac}-thermostat:{id_}" + async_remove_shelly_entity(hass, "climate", unique_id) + else: + continue + switch_ids.append(id_) unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "light", unique_id) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8b4ca0824c4..12d84200720 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -148,6 +148,7 @@ MOCK_CONFIG = { "light:0": {"name": "test light_0"}, "switch:0": {"name": "test switch_0"}, "cover:0": {"name": "test cover_0"}, + "thermostat:0": {"id": 0, "enable": True, "type": "heating"}, "sys": { "ui_data": {}, "device": {"name": "Test name"}, @@ -174,6 +175,7 @@ MOCK_SHELLY_RPC = { "auth_en": False, "auth_domain": None, "profile": "cover", + "relay_operational": False, } MOCK_STATUS_COAP = { @@ -207,6 +209,13 @@ MOCK_STATUS_RPC = { "em1:1": {"act_power": 123.3}, "em1data:0": {"total_act_energy": 123456.4}, "em1data:1": {"total_act_energy": 987654.3}, + "thermostat:0": { + "id": 0, + "enable": True, + "target_C": 23, + "current_C": 12.3, + "output": True, + }, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 08ec548d3f0..d1e37f77574 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -1,10 +1,13 @@ """Tests for Shelly climate platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, PropertyMock from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_TARGET_TEMP_HIGH, @@ -14,13 +17,15 @@ from homeassistant.components.climate import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -534,3 +539,97 @@ async def test_device_not_calibrated( assert not issue_registry.async_get_issue( domain=DOMAIN, issue_id=f"not_calibrated_{MOCK_MAC}" ) + + +async def test_rpc_climate_hvac_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, +) -> None: + """Test climate hvac mode service.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 23 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + + entry = entity_registry.async_get(ENTITY_ID) + assert entry + assert entry.unique_id == "123456789ABC-thermostat:0" + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) + mock_rpc_device.mock_update() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "enable", False) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "Thermostat.SetConfig", {"config": {"id": 0, "enable": False}} + ) + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.OFF + + +async def test_rpc_climate_set_temperature( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test climate set target temperature.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TEMPERATURE] == 23 + + # test set temperature without target temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_LOW: 20, + ATTR_TARGET_TEMP_HIGH: 30, + }, + blocking=True, + ) + mock_rpc_device.call_rpc.assert_not_called() + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "target_C", 28) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 28}, + blocking=True, + ) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "Thermostat.SetConfig", {"config": {"id": 0, "target_C": 28}} + ) + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TEMPERATURE] == 28 + + +async def test_rpc_climate_hvac_mode_cool( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test climate with hvac mode cooling.""" + new_config = deepcopy(mock_rpc_device.config) + new_config["thermostat:0"]["type"] = "cooling" + monkeypatch.setattr(mock_rpc_device, "config", new_config) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 115ad5edabb..9bc065ed166 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,10 +1,12 @@ """Tests for Shelly switch platform.""" +from copy import deepcopy from unittest.mock import AsyncMock from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -19,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, register_entity RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 @@ -277,3 +279,36 @@ async def test_block_device_gas_valve( assert state assert state.state == STATE_ON # valve is open assert state.attributes.get(ATTR_ICON) == "mdi:valve-open" + + +async def test_wall_display_thermostat_mode( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test Wall Display in thermostat mode.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # the switch entity should not be created, only the climate entity + assert hass.states.get("switch.test_name") is None + assert hass.states.get("climate.test_name") + + +async def test_wall_display_relay_mode( + hass: HomeAssistant, entity_registry, mock_rpc_device, monkeypatch +) -> None: + """Test Wall Display in thermostat mode.""" + entity_id = register_entity( + hass, + CLIMATE_DOMAIN, + "test_name", + "thermostat:0", + ) + + new_shelly = deepcopy(mock_rpc_device.shelly) + new_shelly["relay_operational"] = True + + monkeypatch.setattr(mock_rpc_device, "shelly", new_shelly) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # the climate entity should be removed + assert hass.states.get(entity_id) is None From 62473936e22b7429ccd340b1d4a6dba29a1d912c Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 24 Nov 2023 13:06:32 +0100 Subject: [PATCH 712/982] Allow adding items Picnic shopping cart by searching (#102862) Co-authored-by: Robert Resch --- homeassistant/components/picnic/services.py | 4 +- homeassistant/components/picnic/todo.py | 31 ++++++- tests/components/picnic/conftest.py | 43 ++++++++++ .../picnic/snapshots/test_todo.ambr | 55 ++++++++++++ tests/components/picnic/test_todo.py | 83 +++++++++++++++++-- 5 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 tests/components/picnic/snapshots/test_todo.ambr diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 3af2a521f8a..fa00037462d 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -66,7 +66,7 @@ async def handle_add_product( product_id = call.data.get("product_id") if not product_id: product_id = await hass.async_add_executor_job( - _product_search, api_client, cast(str, call.data["product_name"]) + product_search, api_client, cast(str, call.data["product_name"]) ) if not product_id: @@ -77,7 +77,7 @@ async def handle_add_product( ) -def _product_search(api_client: PicnicAPI, product_name: str) -> None | str: +def product_search(api_client: PicnicAPI, product_name: str) -> None | str: """Query the api client for the product name.""" search_result = api_client.search(product_name) diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index 8210702e826..389909ca06e 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -4,7 +4,12 @@ from __future__ import annotations import logging from typing import Any, cast -from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -15,6 +20,7 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import CONF_COORDINATOR, DOMAIN +from .services import product_search _LOGGER = logging.getLogger(__name__) @@ -27,19 +33,20 @@ async def async_setup_entry( """Set up the Picnic shopping cart todo platform config entry.""" picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] - # Add an entity shopping card - async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) + async_add_entities([PicnicCart(hass, picnic_coordinator, config_entry)]) class PicnicCart(TodoListEntity, CoordinatorEntity): """A Picnic Shopping Cart TodoListEntity.""" _attr_has_entity_name = True - _attr_translation_key = "shopping_cart" _attr_icon = "mdi:cart" + _attr_supported_features = TodoListEntityFeature.CREATE_TODO_ITEM + _attr_translation_key = "shopping_cart" def __init__( self, + hass: HomeAssistant, coordinator: DataUpdateCoordinator[Any], config_entry: ConfigEntry, ) -> None: @@ -51,6 +58,7 @@ class PicnicCart(TodoListEntity, CoordinatorEntity): manufacturer="Picnic", model=config_entry.unique_id, ) + self.hass = hass self._attr_unique_id = f"{config_entry.unique_id}-cart" @property @@ -73,3 +81,18 @@ class PicnicCart(TodoListEntity, CoordinatorEntity): ) return items + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add item to shopping cart.""" + product_id = await self.hass.async_add_executor_job( + product_search, self.coordinator.picnic_api_client, item.summary + ) + + if not product_id: + raise ValueError("No product found or no product ID given") + + await self.hass.async_add_executor_job( + self.coordinator.picnic_api_client.add_product, product_id, 1 + ) + + await self.coordinator.async_refresh() diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index 7e36371767d..fb6c99f35e9 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -1,4 +1,5 @@ """Conftest for Picnic tests.""" +from collections.abc import Awaitable, Callable import json from unittest.mock import MagicMock, patch @@ -9,6 +10,9 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +from tests.typing import WebSocketGenerator + +ENTITY_ID = "todo.mock_title_shopping_cart" @pytest.fixture @@ -50,3 +54,42 @@ async def init_integration( await hass.async_block_till_done() return mock_config_entry + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next_id() -> int: + nonlocal id + id += 1 + return id + + return next_id + + +@pytest.fixture +async def get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": ENTITY_ID, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get diff --git a/tests/components/picnic/snapshots/test_todo.ambr b/tests/components/picnic/snapshots/test_todo.ambr new file mode 100644 index 00000000000..4b92584c0fc --- /dev/null +++ b/tests/components/picnic/snapshots/test_todo.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_cart_list_with_items + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Knoflook (2 stuks)', + 'uid': '763-s1001194', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Picnic magere melk (2 x 1 liter)', + 'uid': '765_766-s1046297', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Picnic magere melk (1 liter)', + 'uid': '767-s1010532', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Robijn wascapsules wit (40 wasbeurten)', + 'uid': '774_775-s1018253', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Robijn wascapsules kleur (15 wasbeurten)', + 'uid': '774_775-s1007025', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Chinese wokgroenten (600 gram)', + 'uid': '776_777_778_779_780-s1012699', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Picnic boerderij-eitjes (6 stuks M/L)', + 'uid': '776_777_778_779_780-s1003425', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Picnic witte snelkookrijst (400 gram)', + 'uid': '776_777_778_779_780-s1016692', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Conimex kruidenmix nasi (20 gram)', + 'uid': '776_777_778_779_780-s1012503', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Conimex satésaus mild kant & klaar (400 gram)', + 'uid': '776_777_778_779_780-s1005028', + }), + ]) +# --- diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py index 675651dc588..a65fb83ca95 100644 --- a/tests/components/picnic/test_todo.py +++ b/tests/components/picnic/test_todo.py @@ -1,18 +1,31 @@ """Tests for Picnic Tasks todo platform.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.todo import DOMAIN from homeassistant.core import HomeAssistant +from .conftest import ENTITY_ID + from tests.common import MockConfigEntry -async def test_cart_list_with_items(hass: HomeAssistant, init_integration) -> None: +async def test_cart_list_with_items( + hass: HomeAssistant, + init_integration, + get_items, + snapshot: SnapshotAssertion, +) -> None: """Test loading of shopping cart.""" - state = hass.states.get("todo.mock_title_shopping_cart") + state = hass.states.get(ENTITY_ID) assert state assert state.state == "10" + assert snapshot == await get_items() + async def test_cart_list_empty_items( hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry @@ -23,7 +36,7 @@ async def test_cart_list_empty_items( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("todo.mock_title_shopping_cart") + state = hass.states.get(ENTITY_ID) assert state assert state.state == "0" @@ -37,7 +50,7 @@ async def test_cart_list_unexpected_response( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("todo.mock_title_shopping_cart") + state = hass.states.get(ENTITY_ID) assert state is None @@ -50,5 +63,63 @@ async def test_cart_list_null_response( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("todo.mock_title_shopping_cart") + state = hass.states.get(ENTITY_ID) assert state is None + + +async def test_create_todo_list_item( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_picnic_api: MagicMock +) -> None: + """Test for creating a picnic cart item.""" + assert len(mock_picnic_api.get_cart.mock_calls) == 1 + + mock_picnic_api.search = Mock() + mock_picnic_api.search.return_value = [ + { + "items": [ + { + "id": 321, + "name": "Picnic Melk", + "unit_quantity": "2 liter", + } + ] + } + ] + + mock_picnic_api.add_product = Mock() + + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "Melk"}, + target={"entity_id": ENTITY_ID}, + blocking=True, + ) + + args = mock_picnic_api.search.call_args + assert args + assert args[0][0] == "Melk" + + args = mock_picnic_api.add_product.call_args + assert args + assert args[0][0] == "321" + assert args[0][1] == 1 + + assert len(mock_picnic_api.get_cart.mock_calls) == 2 + + +async def test_create_todo_list_item_not_found( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_picnic_api: MagicMock +) -> None: + """Test for creating a picnic cart item when ID is not found.""" + mock_picnic_api.search = Mock() + mock_picnic_api.search.return_value = [{"items": []}] + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "Melk"}, + target={"entity_id": ENTITY_ID}, + blocking=True, + ) From df025b5993e58fca34535e085681adf279db34c7 Mon Sep 17 00:00:00 2001 From: Joseph <1315585+joseph39@users.noreply.github.com> Date: Fri, 24 Nov 2023 04:14:44 -0800 Subject: [PATCH 713/982] Enumerate openai.Models to validate config (#99438) --- homeassistant/components/openai_conversation/__init__.py | 2 +- homeassistant/components/openai_conversation/config_flow.py | 2 +- tests/components/openai_conversation/conftest.py | 2 +- tests/components/openai_conversation/test_config_flow.py | 4 ++-- tests/components/openai_conversation/test_init.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 0279580e56b..054ccbdbe37 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -89,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await hass.async_add_executor_job( partial( - openai.Engine.list, + openai.Model.list, api_key=entry.data[CONF_API_KEY], request_timeout=10, ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index b391f531eb1..9c5ef32d796 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -60,7 +60,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ openai.api_key = data[CONF_API_KEY] - await hass.async_add_executor_job(partial(openai.Engine.list, request_timeout=10)) + await hass.async_add_executor_job(partial(openai.Model.list, request_timeout=10)) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 9f00290600e..40f2eb33f08 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -25,7 +25,7 @@ def mock_config_entry(hass): async def mock_init_component(hass, mock_config_entry): """Initialize integration.""" with patch( - "openai.Engine.list", + "openai.Model.list", ): assert await async_setup_component(hass, "openai_conversation", {}) await hass.async_block_till_done() diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 471be8035b6..43dfc26ca82 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -32,7 +32,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.openai_conversation.config_flow.openai.Engine.list", + "homeassistant.components.openai_conversation.config_flow.openai.Model.list", ), patch( "homeassistant.components.openai_conversation.async_setup_entry", return_value=True, @@ -88,7 +88,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.openai_conversation.config_flow.openai.Engine.list", + "homeassistant.components.openai_conversation.config_flow.openai.Model.list", side_effect=side_effect, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 1b145d9d545..61fe33e5469 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -140,7 +140,7 @@ async def test_template_error( }, ) with patch( - "openai.Engine.list", + "openai.Model.list", ), patch("openai.ChatCompletion.acreate"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() From 65a2f5bcd562dc0ed2befa224411a8c658583938 Mon Sep 17 00:00:00 2001 From: Hejki <32815+Hejki@users.noreply.github.com> Date: Fri, 24 Nov 2023 13:19:25 +0100 Subject: [PATCH 714/982] Support for group into command_line auth provider (#92906) Co-authored-by: Franck Nijhof Co-authored-by: Erik Montnemery --- homeassistant/auth/__init__.py | 3 ++- homeassistant/auth/models.py | 2 ++ homeassistant/auth/providers/command_line.py | 15 ++++++++++++--- tests/auth/providers/test_command_line.py | 6 ++++++ tests/auth/providers/test_command_line_cmd.sh | 2 ++ 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 2707f8b6899..000dde90faa 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -280,7 +280,8 @@ class AuthManager: credentials=credentials, name=info.name, is_active=info.is_active, - group_ids=[GROUP_ID_ADMIN], + group_ids=[GROUP_ID_ADMIN if info.group is None else info.group], + local_only=info.local_only, ) self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id}) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index e604bf9d21c..32a700d65f9 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -134,3 +134,5 @@ class UserMeta(NamedTuple): name: str | None is_active: bool + group: str | None = None + local_only: bool | None = None diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index bfe8a2fdddb..4ec2ca18611 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -44,7 +44,11 @@ class CommandLineAuthProvider(AuthProvider): DEFAULT_TITLE = "Command Line Authentication" # which keys to accept from a program's stdout - ALLOWED_META_KEYS = ("name",) + ALLOWED_META_KEYS = ( + "name", + "group", + "local_only", + ) def __init__(self, *args: Any, **kwargs: Any) -> None: """Extend parent's __init__. @@ -118,10 +122,15 @@ class CommandLineAuthProvider(AuthProvider): ) -> UserMeta: """Return extra user metadata for credentials. - Currently, only name is supported. + Currently, supports name, group and local_only. """ meta = self._user_meta.get(credentials.data["username"], {}) - return UserMeta(name=meta.get("name"), is_active=True) + return UserMeta( + name=meta.get("name"), + is_active=True, + group=meta.get("group"), + local_only=meta.get("local_only") == "true", + ) class CommandLineLoginFlow(LoginFlow): diff --git a/tests/auth/providers/test_command_line.py b/tests/auth/providers/test_command_line.py index 97f8f659397..a92d41a8c5f 100644 --- a/tests/auth/providers/test_command_line.py +++ b/tests/auth/providers/test_command_line.py @@ -50,6 +50,9 @@ async def test_create_new_credential(manager, provider) -> None: user = await manager.async_get_or_create_user(credentials) assert user.is_active + assert len(user.groups) == 1 + assert user.groups[0].id == "system-admin" + assert not user.local_only async def test_match_existing_credentials(store, provider) -> None: @@ -100,6 +103,9 @@ async def test_good_auth_with_meta(manager, provider) -> None: user = await manager.async_get_or_create_user(credentials) assert user.name == "Bob" assert user.is_active + assert len(user.groups) == 1 + assert user.groups[0].id == "system-users" + assert user.local_only async def test_utf_8_username_password(provider) -> None: diff --git a/tests/auth/providers/test_command_line_cmd.sh b/tests/auth/providers/test_command_line_cmd.sh index 0e689e338f1..4cbd7946a29 100755 --- a/tests/auth/providers/test_command_line_cmd.sh +++ b/tests/auth/providers/test_command_line_cmd.sh @@ -4,6 +4,8 @@ if [ "$username" = "good-user" ] && [ "$password" = "good-pass" ]; then echo "Auth should succeed." >&2 if [ "$1" = "--with-meta" ]; then echo "name=Bob" + echo "group=system-users" + echo "local_only=true" fi exit 0 fi From a1701f0c562f4736f356481aabdcf6f79b23f5a7 Mon Sep 17 00:00:00 2001 From: dotvav Date: Fri, 24 Nov 2023 15:00:36 +0100 Subject: [PATCH 715/982] Support HitachiAirToAirHeatPump (hlrrwifi:HLinkMainController) in Overkiz (#103803) --- homeassistant/components/overkiz/climate.py | 15 +- .../overkiz/climate_entities/__init__.py | 9 + .../hitachi_air_to_air_heat_pump_hlrrwifi.py | 279 ++++++++++++++++++ 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py diff --git a/homeassistant/components/overkiz/climate.py b/homeassistant/components/overkiz/climate.py index a94c731ec8f..b6d31a8e685 100644 --- a/homeassistant/components/overkiz/climate.py +++ b/homeassistant/components/overkiz/climate.py @@ -7,7 +7,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData -from .climate_entities import WIDGET_TO_CLIMATE_ENTITY +from .climate_entities import ( + WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY, + WIDGET_TO_CLIMATE_ENTITY, +) from .const import DOMAIN @@ -24,3 +27,13 @@ async def async_setup_entry( for device in data.platforms[Platform.CLIMATE] if device.widget in WIDGET_TO_CLIMATE_ENTITY ) + + # Hitachi Air To Air Heat Pumps + async_add_entities( + WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( + device.device_url, data.coordinator + ) + for device in data.platforms[Platform.CLIMATE] + if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY + and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget] + ) diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index b6345dd9b95..c74ff2829cc 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -1,4 +1,5 @@ """Climate entities for the Overkiz (by Somfy) integration.""" +from pyoverkiz.enums import Protocol from pyoverkiz.enums.ui import UIWidget from .atlantic_electrical_heater import AtlanticElectricalHeater @@ -9,6 +10,7 @@ from .atlantic_electrical_towel_dryer import AtlanticElectricalTowelDryer from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl +from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface @@ -26,3 +28,10 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } + +# Hitachi air-to-air heatpumps come in 2 flavors (HLRRWIFI and OVP) that are separated in 2 classes +WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { + UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { + Protocol.HLRR_WIFI: HitachiAirToAirHeatPumpHLRRWIFI, + }, +} diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py new file mode 100644 index 00000000000..fcb83884694 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py @@ -0,0 +1,279 @@ +"""Support for HitachiAirToAirHeatPump.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_NONE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..const import DOMAIN +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_HOLIDAY_MODE = "holiday_mode" +FAN_SILENT = "silent" +FAN_SPEED_STATE = OverkizState.HLRRWIFI_FAN_SPEED +LEAVE_HOME_STATE = OverkizState.HLRRWIFI_LEAVE_HOME +MAIN_OPERATION_STATE = OverkizState.HLRRWIFI_MAIN_OPERATION +MODE_CHANGE_STATE = OverkizState.HLRRWIFI_MODE_CHANGE +ROOM_TEMPERATURE_STATE = OverkizState.HLRRWIFI_ROOM_TEMPERATURE +SWING_STATE = OverkizState.HLRRWIFI_SWING + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTOHEATING: HVACMode.AUTO, + OverkizCommandParam.AUTOCOOLING: HVACMode.AUTO, + OverkizCommandParam.ON: HVACMode.HEAT, + OverkizCommandParam.OFF: HVACMode.OFF, + OverkizCommandParam.HEATING: HVACMode.HEAT, + OverkizCommandParam.FAN: HVACMode.FAN_ONLY, + OverkizCommandParam.DEHUMIDIFY: HVACMode.DRY, + OverkizCommandParam.COOLING: HVACMode.COOL, + OverkizCommandParam.AUTO: HVACMode.AUTO, +} + +HVAC_MODES_TO_OVERKIZ: dict[HVACMode, str] = { + HVACMode.AUTO: OverkizCommandParam.AUTO, + HVACMode.HEAT: OverkizCommandParam.HEATING, + HVACMode.OFF: OverkizCommandParam.AUTO, + HVACMode.FAN_ONLY: OverkizCommandParam.FAN, + HVACMode.DRY: OverkizCommandParam.DEHUMIDIFY, + HVACMode.COOL: OverkizCommandParam.COOLING, +} + +OVERKIZ_TO_SWING_MODES: dict[str, str] = { + OverkizCommandParam.BOTH: SWING_BOTH, + OverkizCommandParam.HORIZONTAL: SWING_HORIZONTAL, + OverkizCommandParam.STOP: SWING_OFF, + OverkizCommandParam.VERTICAL: SWING_VERTICAL, +} + +SWING_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_SWING_MODES.items()} + +OVERKIZ_TO_FAN_MODES: dict[str, str] = { + OverkizCommandParam.AUTO: FAN_AUTO, + OverkizCommandParam.HIGH: FAN_HIGH, + OverkizCommandParam.LOW: FAN_LOW, + OverkizCommandParam.MEDIUM: FAN_MEDIUM, + OverkizCommandParam.SILENT: FAN_SILENT, +} + +FAN_MODES_TO_OVERKIZ: dict[str, str] = { + FAN_AUTO: OverkizCommandParam.AUTO, + FAN_HIGH: OverkizCommandParam.HIGH, + FAN_LOW: OverkizCommandParam.LOW, + FAN_MEDIUM: OverkizCommandParam.MEDIUM, + FAN_SILENT: OverkizCommandParam.SILENT, +} + + +class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): + """Representation of Hitachi Air To Air HeatPump.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [PRESET_NONE, PRESET_HOLIDAY_MODE] + _attr_swing_modes = [*SWING_MODES_TO_OVERKIZ] + _attr_target_temperature_step = 1.0 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + + if self.device.states.get(SWING_STATE): + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "Hitachi" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if ( + main_op_state := self.device.states[MAIN_OPERATION_STATE] + ) and main_op_state.value_as_str: + if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF: + return HVACMode.OFF + + if ( + mode_change_state := self.device.states[MODE_CHANGE_STATE] + ) and mode_change_state.value_as_str: + sanitized_value = mode_change_state.value_as_str.lower() + return OVERKIZ_TO_HVAC_MODES[sanitized_value] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF: + await self._global_control(main_operation=OverkizCommandParam.OFF) + else: + await self._global_control( + main_operation=OverkizCommandParam.ON, + hvac_mode=HVAC_MODES_TO_OVERKIZ[hvac_mode], + ) + + @property + def fan_mode(self) -> str | None: + """Return the fan setting.""" + if (state := self.device.states[FAN_SPEED_STATE]) and state.value_as_str: + return OVERKIZ_TO_FAN_MODES[state.value_as_str] + + return None + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes.""" + return [*FAN_MODES_TO_OVERKIZ] + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self._global_control(fan_mode=FAN_MODES_TO_OVERKIZ[fan_mode]) + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + if (state := self.device.states[SWING_STATE]) and state.value_as_str: + return OVERKIZ_TO_SWING_MODES[state.value_as_str] + + return None + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + await self._global_control(swing_mode=SWING_MODES_TO_OVERKIZ[swing_mode]) + + @property + def target_temperature(self) -> int | None: + """Return the temperature.""" + if ( + temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE] + ) and temperature.value_as_int: + return temperature.value_as_int + + return None + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if (state := self.device.states[ROOM_TEMPERATURE_STATE]) and state.value_as_int: + return state.value_as_int + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) + await self._global_control(target_temperature=int(temperature)) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if (state := self.device.states[LEAVE_HOME_STATE]) and state.value_as_str: + if state.value_as_str == OverkizCommandParam.ON: + return PRESET_HOLIDAY_MODE + + if state.value_as_str == OverkizCommandParam.OFF: + return PRESET_NONE + + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_HOLIDAY_MODE: + await self._global_control(leave_home=OverkizCommandParam.ON) + + if preset_mode == PRESET_NONE: + await self._global_control(leave_home=OverkizCommandParam.OFF) + + def _control_backfill( + self, value: str | None, state_name: str, fallback_value: str + ) -> str: + """Overkiz doesn't accept commands with undefined parameters. This function is guaranteed to return a `str` which is the provided `value` if set, or the current device state if set, or the provided `fallback_value` otherwise.""" + if value: + return value + state = self.device.states[state_name] + if state and state.value_as_str: + return state.value_as_str + return fallback_value + + async def _global_control( + self, + main_operation: str | None = None, + target_temperature: int | None = None, + fan_mode: str | None = None, + hvac_mode: str | None = None, + swing_mode: str | None = None, + leave_home: str | None = None, + ) -> None: + """Execute globalControl command with all parameters. There is no option to only set a single parameter, without passing all other values.""" + + main_operation = self._control_backfill( + main_operation, MAIN_OPERATION_STATE, OverkizCommandParam.ON + ) + target_temperature = target_temperature or self.target_temperature + + fan_mode = self._control_backfill( + fan_mode, + FAN_SPEED_STATE, + OverkizCommandParam.AUTO, + ) + hvac_mode = self._control_backfill( + hvac_mode, + MODE_CHANGE_STATE, + OverkizCommandParam.AUTO, + ).lower() # Overkiz can return states that have uppercase characters which are not accepted back as commands + if hvac_mode.replace( + " ", "" + ) in [ # Overkiz can return states like 'auto cooling' or 'autoHeating' that are not valid commands and need to be converted to 'auto' + OverkizCommandParam.AUTOCOOLING, + OverkizCommandParam.AUTOHEATING, + ]: + hvac_mode = OverkizCommandParam.AUTO + + swing_mode = self._control_backfill( + swing_mode, + SWING_STATE, + OverkizCommandParam.STOP, + ) + + leave_home = self._control_backfill( + leave_home, + LEAVE_HOME_STATE, + OverkizCommandParam.OFF, + ) + + command_data = [ + main_operation, # Main Operation + target_temperature, # Target Temperature + fan_mode, # Fan Mode + hvac_mode, # Mode + swing_mode, # Swing Mode + leave_home, # Leave Home + ] + + await self.executor.async_execute_command( + OverkizCommand.GLOBAL_CONTROL, *command_data + ) From 512902fc59e1c3b0d7faec4e72745e7b5918e39f Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 24 Nov 2023 11:02:19 -0500 Subject: [PATCH 716/982] Add Switch platform for motion detection in Blink (#102789) Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + .../components/blink/binary_sensor.py | 2 + homeassistant/components/blink/const.py | 1 + homeassistant/components/blink/strings.json | 5 + homeassistant/components/blink/switch.py | 99 +++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 homeassistant/components/blink/switch.py diff --git a/.coveragerc b/.coveragerc index f28ef24e4b2..00116c658ca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -120,6 +120,7 @@ omit = homeassistant/components/blink/binary_sensor.py homeassistant/components/blink/camera.py homeassistant/components/blink/sensor.py + homeassistant/components/blink/switch.py homeassistant/components/blinksticklight/light.py homeassistant/components/blockchain/sensor.py homeassistant/components/bloomsky/* diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 9400e79838b..8598868e2dc 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -32,9 +32,11 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + # Camera Armed sensor is depreciated covered by switch and will be removed in 2023.6. BinarySensorEntityDescription( key=TYPE_CAMERA_ARMED, translation_key="camera_armed", + entity_registry_enabled_default=False, ), BinarySensorEntityDescription( key=TYPE_MOTION_DETECTED, diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 7de42a80efc..d394b5c0008 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -30,4 +30,5 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 85556bbcd5a..c29c4c765b7 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -47,6 +47,11 @@ "camera_armed": { "name": "Camera armed" } + }, + "switch": { + "camera_motion": { + "name": "Camera motion detection" + } } }, "services": { diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py new file mode 100644 index 00000000000..197c8e08685 --- /dev/null +++ b/homeassistant/components/blink/switch.py @@ -0,0 +1,99 @@ +"""Support for Blink Motion detection switches.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_BRAND, DOMAIN, TYPE_CAMERA_ARMED +from .coordinator import BlinkUpdateCoordinator + +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key=TYPE_CAMERA_ARMED, + icon="mdi:motion-sensor", + translation_key="camera_motion", + device_class=SwitchDeviceClass.SWITCH, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Blink switches.""" + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] + + async_add_entities( + BlinkSwitch(coordinator, camera, description) + for camera in coordinator.api.cameras + for description in SWITCH_TYPES + ) + + +class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity): + """Representation of a Blink motion detection switch.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BlinkUpdateCoordinator, + camera, + description: SwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self._camera = coordinator.api.cameras[camera] + self.entity_description = description + serial = self._camera.serial + self._attr_unique_id = f"{serial}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + serial_number=serial, + name=camera, + manufacturer=DEFAULT_BRAND, + model=self._camera.camera_type, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self._camera.async_arm(True) + + except asyncio.TimeoutError as er: + raise HomeAssistantError( + "Blink failed to arm camera motion detection" + ) from er + + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self._camera.async_arm(False) + + except asyncio.TimeoutError as er: + raise HomeAssistantError( + "Blink failed to dis-arm camera motion detection" + ) from er + + await self.coordinator.async_refresh() + + @property + def is_on(self) -> bool: + """Return if Camera Motion is enabled.""" + return self._camera.motion_enabled From 852fb58ca82d39a404b1d8a44b71fae17b8ecfdf Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 24 Nov 2023 17:11:54 +0100 Subject: [PATCH 717/982] Extend `auth/providers` endpoint and add `/api/person/list` endpoint for local ip requests (#103906) Co-authored-by: Martin Hjelmare --- .../auth/providers/trusted_networks.py | 8 +- homeassistant/components/auth/login_flow.py | 71 ++++++++-- homeassistant/components/http/auth.py | 9 +- homeassistant/components/person/__init__.py | 49 +++++++ homeassistant/components/person/manifest.json | 2 +- homeassistant/components/webhook/__init__.py | 11 +- homeassistant/helpers/network.py | 11 ++ tests/components/auth/__init__.py | 16 ++- tests/components/auth/test_login_flow.py | 130 +++++++++++++++++- tests/components/http/__init__.py | 31 ----- tests/components/http/test_auth.py | 3 +- tests/components/http/test_ban.py | 3 +- tests/components/person/test_init.py | 65 ++++++++- tests/test_util/__init__.py | 36 ++++- 14 files changed, 370 insertions(+), 75 deletions(-) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 6962671cb2f..cc195c14c23 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -22,6 +22,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.network import is_cloud_connection from .. import InvalidAuthError from ..models import Credentials, RefreshToken, UserMeta @@ -192,11 +193,8 @@ class TrustedNetworksAuthProvider(AuthProvider): if any(ip_addr in trusted_proxy for trusted_proxy in self.trusted_proxies): raise InvalidAuthError("Can't allow access from a proxy server") - if "cloud" in self.hass.config.components: - from hass_nabucasa import remote # pylint: disable=import-outside-toplevel - - if remote.is_cloud_request.get(): - raise InvalidAuthError("Can't allow access from Home Assistant Cloud") + if is_cloud_connection(self.hass): + raise InvalidAuthError("Can't allow access from Home Assistant Cloud") @callback def async_validate_refresh_token( diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index e0cc0eeb1ec..96255f59c7b 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -71,14 +71,14 @@ from __future__ import annotations from collections.abc import Callable from http import HTTPStatus from ipaddress import ip_address -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from aiohttp import web import voluptuous as vol import voluptuous_serialize from homeassistant import data_entry_flow -from homeassistant.auth import AuthManagerFlowManager +from homeassistant.auth import AuthManagerFlowManager, InvalidAuthError from homeassistant.auth.models import Credentials from homeassistant.components import onboarding from homeassistant.components.http.auth import async_user_not_allowed_do_auth @@ -90,10 +90,16 @@ from homeassistant.components.http.ban import ( from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local from . import indieauth if TYPE_CHECKING: + from homeassistant.auth.providers.trusted_networks import ( + TrustedNetworksAuthProvider, + ) + from . import StoreResultType @@ -146,12 +152,61 @@ class AuthProvidersView(HomeAssistantView): message_code="onboarding_required", ) - return self.json( - [ - {"name": provider.name, "id": provider.id, "type": provider.type} - for provider in hass.auth.auth_providers - ] - ) + try: + remote_address = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + return self.json_message( + message="Invalid remote IP", + status_code=HTTPStatus.BAD_REQUEST, + message_code="invalid_remote_ip", + ) + + cloud_connection = is_cloud_connection(hass) + + providers = [] + for provider in hass.auth.auth_providers: + additional_data = {} + + if provider.type == "trusted_networks": + if cloud_connection: + # Skip quickly as trusted networks are not available on cloud + continue + + try: + cast("TrustedNetworksAuthProvider", provider).async_validate_access( + remote_address + ) + except InvalidAuthError: + # Not a trusted network, so we don't expose that trusted_network authenticator is setup + continue + elif ( + provider.type == "homeassistant" + and not cloud_connection + and is_local(remote_address) + and "person" in hass.config.components + ): + # We are local, return user id and username + users = await provider.store.async_get_users() + additional_data["users"] = { + user.id: credentials.data["username"] + for user in users + for credentials in user.credentials + if ( + credentials.auth_provider_type == provider.type + and credentials.auth_provider_id == provider.id + ) + } + + providers.append( + { + "name": provider.name, + "id": provider.id, + "type": provider.type, + **additional_data, + } + ) + + return self.json(providers) def _prepare_result_json( diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index fc7b3c03abe..618bab91f7f 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -21,6 +21,7 @@ from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_bytes +from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store from homeassistant.util.network import is_local @@ -98,12 +99,8 @@ def async_user_not_allowed_do_auth( if not request: return "No request available to validate local access" - if "cloud" in hass.config.components: - # pylint: disable-next=import-outside-toplevel - from hass_nabucasa import remote - - if remote.is_cloud_request.get(): - return "User is local only" + if is_cloud_connection(hass): + return "User is local only" try: remote_address = ip_address(request.remote) # type: ignore[arg-type] diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 49b719a5490..b6f8b5b2db6 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,9 +1,12 @@ """Support for tracking people.""" from __future__ import annotations +from http import HTTPStatus +from ipaddress import ip_address import logging from typing import Any +from aiohttp import web import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED @@ -13,6 +16,7 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, ) +from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import ( ATTR_EDITABLE, ATTR_ENTITY_ID, @@ -47,10 +51,12 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.network import is_local _LOGGER = logging.getLogger(__name__) @@ -385,6 +391,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml ) + hass.http.register_view(ListPersonsView) + return True @@ -569,3 +577,44 @@ def _get_latest(prev: State | None, curr: State): if prev is None or curr.last_updated > prev.last_updated: return curr return prev + + +class ListPersonsView(HomeAssistantView): + """List all persons if request is made from a local network.""" + + requires_auth = False + url = "/api/person/list" + name = "api:person:list" + + async def get(self, request: web.Request) -> web.Response: + """Return a list of persons if request comes from a local IP.""" + try: + remote_address = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + return self.json_message( + message="Invalid remote IP", + status_code=HTTPStatus.BAD_REQUEST, + message_code="invalid_remote_ip", + ) + + hass: HomeAssistant = request.app["hass"] + if is_cloud_connection(hass) or not is_local(remote_address): + return self.json_message( + message="Not local", + status_code=HTTPStatus.BAD_REQUEST, + message_code="not_local", + ) + + yaml, storage, _ = hass.data[DOMAIN] + persons = [*yaml.async_items(), *storage.async_items()] + + return self.json( + { + person[ATTR_USER_ID]: { + ATTR_NAME: person[ATTR_NAME], + CONF_PICTURE: person.get(CONF_PICTURE), + } + for person in persons + if person.get(ATTR_USER_ID) + } + ) diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json index f6682058dae..7f370be6fbe 100644 --- a/homeassistant/components/person/manifest.json +++ b/homeassistant/components/person/manifest.json @@ -3,7 +3,7 @@ "name": "Person", "after_dependencies": ["device_tracker"], "codeowners": [], - "dependencies": ["image_upload"], + "dependencies": ["image_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/person", "integration_type": "system", "iot_class": "calculated", diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 5f82ca54283..16f3e5c7ef2 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.network import get_url +from homeassistant.helpers.network import get_url, is_cloud_connection from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import network @@ -145,13 +145,8 @@ async def async_handle_webhook( return Response(status=HTTPStatus.METHOD_NOT_ALLOWED) if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest): - if has_cloud := "cloud" in hass.config.components: - from hass_nabucasa import remote # pylint: disable=import-outside-toplevel - - is_local = True - if has_cloud and remote.is_cloud_request.get(): - is_local = False - else: + is_local = not is_cloud_connection(hass) + if is_local: if TYPE_CHECKING: assert isinstance(request, Request) assert request.remote is not None diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 12accf2725a..58ca191feb0 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -299,3 +299,14 @@ def _get_cloud_url(hass: HomeAssistant, require_current_request: bool = False) - return normalize_url(str(cloud_url)) raise NoURLAvailableError + + +def is_cloud_connection(hass: HomeAssistant) -> bool: + """Return True if the current connection is a nabucasa cloud connection.""" + + if "cloud" not in hass.config.components: + return False + + from hass_nabucasa import remote # pylint: disable=import-outside-toplevel + + return remote.is_cloud_request.get() diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index 7ce65964086..8b731934913 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -1,8 +1,13 @@ """Tests for the auth component.""" +from typing import Any + from homeassistant import auth +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import ensure_auth_manager_loaded +from tests.test_util import mock_real_ip +from tests.typing import ClientSessionGenerator BASE_CONFIG = [ { @@ -18,11 +23,12 @@ EMPTY_CONFIG = [] async def async_setup_auth( - hass, - aiohttp_client, - provider_configs=BASE_CONFIG, + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + provider_configs: list[dict[str, Any]] = BASE_CONFIG, module_configs=EMPTY_CONFIG, - setup_api=False, + setup_api: bool = False, + custom_ip: str | None = None, ): """Set up authentication and create an HTTP client.""" hass.auth = await auth.auth_manager_from_config( @@ -32,4 +38,6 @@ async def async_setup_auth( await async_setup_component(hass, "auth", {}) if setup_api: await async_setup_component(hass, "api", {}) + if custom_ip: + mock_real_ip(hass.http.app)(custom_ip) return await aiohttp_client(hass.http.app) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index b44d8fb4a11..639bbb9a9cb 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -1,25 +1,141 @@ """Tests for the login flow.""" +from collections.abc import Callable from http import HTTPStatus +from typing import Any from unittest.mock import patch -from homeassistant.core import HomeAssistant +import pytest -from . import async_setup_auth +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import BASE_CONFIG, async_setup_auth from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI from tests.typing import ClientSessionGenerator +_TRUSTED_NETWORKS_CONFIG = { + "type": "trusted_networks", + "trusted_networks": ["192.168.0.1"], + "trusted_users": { + "192.168.0.1": [ + "a1ab982744b64757bf80515589258924", + {"group": "system-group"}, + ] + }, +} + +@pytest.mark.parametrize( + ("provider_configs", "ip", "expected"), + [ + ( + BASE_CONFIG, + None, + [{"name": "Example", "type": "insecure_example", "id": None}], + ), + ( + [_TRUSTED_NETWORKS_CONFIG], + None, + [], + ), + ( + [_TRUSTED_NETWORKS_CONFIG], + "192.168.0.1", + [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], + ), + ], +) async def test_fetch_auth_providers( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + provider_configs: list[dict[str, Any]], + ip: str | None, + expected: list[dict[str, Any]], ) -> None: """Test fetching auth providers.""" - client = await async_setup_auth(hass, aiohttp_client) + client = await async_setup_auth( + hass, aiohttp_client, provider_configs, custom_ip=ip + ) resp = await client.get("/auth/providers") assert resp.status == HTTPStatus.OK - assert await resp.json() == [ - {"name": "Example", "type": "insecure_example", "id": None} - ] + assert await resp.json() == expected + + +async def _test_fetch_auth_providers_home_assistant( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + ip: str, + additional_expected_fn: Callable[[User], dict[str, Any]], +) -> None: + """Test fetching auth providers for homeassistant auth provider.""" + client = await async_setup_auth( + hass, aiohttp_client, [{"type": "homeassistant"}], custom_ip=ip + ) + + provider = hass.auth.auth_providers[0] + credentials = await provider.async_get_or_create_credentials({"username": "hello"}) + user = await hass.auth.async_get_or_create_user(credentials) + + expected = { + "name": "Home Assistant Local", + "type": "homeassistant", + "id": None, + **additional_expected_fn(user), + } + + resp = await client.get("/auth/providers") + assert resp.status == HTTPStatus.OK + assert await resp.json() == [expected] + + +@pytest.mark.parametrize( + "ip", + [ + "192.168.0.10", + "::ffff:192.168.0.10", + "1.2.3.4", + "2001:db8::1", + ], +) +async def test_fetch_auth_providers_home_assistant_person_not_loaded( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + ip: str, +) -> None: + """Test fetching auth providers for homeassistant auth provider, where person integration is not loaded.""" + await _test_fetch_auth_providers_home_assistant( + hass, aiohttp_client, ip, lambda _: {} + ) + + +@pytest.mark.parametrize( + ("ip", "is_local"), + [ + ("192.168.0.10", True), + ("::ffff:192.168.0.10", True), + ("1.2.3.4", False), + ("2001:db8::1", False), + ], +) +async def test_fetch_auth_providers_home_assistant_person_loaded( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + ip: str, + is_local: bool, +) -> None: + """Test fetching auth providers for homeassistant auth provider, where person integration is loaded.""" + domain = "person" + config = {domain: {"id": "1234", "name": "test person"}} + assert await async_setup_component(hass, domain, config) + + await _test_fetch_auth_providers_home_assistant( + hass, + aiohttp_client, + ip, + lambda user: {"users": {user.id: user.name}} if is_local else {}, + ) async def test_fetch_auth_providers_onboarding( diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py index 238f5c7050a..cd1d5916ab8 100644 --- a/tests/components/http/__init__.py +++ b/tests/components/http/__init__.py @@ -1,34 +1,3 @@ """Tests for the HTTP component.""" -from aiohttp import web - # Relic from the past. Kept here so we can run negative tests. HTTP_HEADER_HA_AUTH = "X-HA-access" - - -def mock_real_ip(app): - """Inject middleware to mock real IP. - - Returns a function to set the real IP. - """ - ip_to_mock = None - - def set_ip_to_mock(value): - nonlocal ip_to_mock - ip_to_mock = value - - @web.middleware - async def mock_real_ip(request, handler): - """Mock Real IP middleware.""" - nonlocal ip_to_mock - - request = request.clone(remote=ip_to_mock) - - return await handler(request) - - async def real_ip_startup(app): - """Startup of real ip.""" - app.middlewares.insert(0, mock_real_ip) - - app.on_startup.append(real_ip_startup) - - return set_ip_to_mock diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 246572e64f8..2f1259c22de 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -35,9 +35,10 @@ from homeassistant.components.http.request_context import ( from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -from . import HTTP_HEADER_HA_AUTH, mock_real_ip +from . import HTTP_HEADER_HA_AUTH from tests.common import MockUser +from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator, WebSocketGenerator API_PASSWORD = "test-password" diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index d1123a7009e..e38a9c97071 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -24,9 +24,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from . import mock_real_ip - from tests.common import async_get_persistent_notifications +from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator SUPERVISOR_IP = "1.2.3.4" diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 71491ee3caf..4d7781a095f 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,4 +1,6 @@ """The tests for the person component.""" +from collections.abc import Callable +from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -29,7 +31,8 @@ from homeassistant.setup import async_setup_component from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 from tests.common import MockUser, mock_component, mock_restore_cache -from tests.typing import WebSocketGenerator +from tests.test_util import mock_real_ip +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_minimal_setup(hass: HomeAssistant) -> None: @@ -847,3 +850,63 @@ async def test_entities_in_person(hass: HomeAssistant) -> None: "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ] + + +@pytest.mark.parametrize( + ("ip", "status_code", "expected_fn"), + [ + ( + "192.168.0.10", + HTTPStatus.OK, + lambda user: { + user["user_id"]: {"name": user["name"], "picture": user["picture"]} + }, + ), + ( + "::ffff:192.168.0.10", + HTTPStatus.OK, + lambda user: { + user["user_id"]: {"name": user["name"], "picture": user["picture"]} + }, + ), + ( + "1.2.3.4", + HTTPStatus.BAD_REQUEST, + lambda _: {"code": "not_local", "message": "Not local"}, + ), + ( + "2001:db8::1", + HTTPStatus.BAD_REQUEST, + lambda _: {"code": "not_local", "message": "Not local"}, + ), + ], +) +async def test_list_persons( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_admin_user: MockUser, + ip: str, + status_code: HTTPStatus, + expected_fn: Callable[[dict[str, Any]], dict[str, Any]], +) -> None: + """Test listing persons from a not local ip address.""" + + user_id = hass_admin_user.id + admin = {"id": "1234", "name": "Admin", "user_id": user_id, "picture": "/bla"} + config = { + DOMAIN: [ + admin, + {"id": "5678", "name": "Only a person"}, + ] + } + assert await async_setup_component(hass, DOMAIN, config) + + await async_setup_component(hass, "api", {}) + mock_real_ip(hass.http.app)(ip) + client = await hass_client_no_auth() + + resp = await client.get("/api/person/list") + + assert resp.status == status_code + result = await resp.json() + assert result == expected_fn(admin) diff --git a/tests/test_util/__init__.py b/tests/test_util/__init__.py index b8499675ea2..fe2c2c640e5 100644 --- a/tests/test_util/__init__.py +++ b/tests/test_util/__init__.py @@ -1 +1,35 @@ -"""Tests for the test utilities.""" +"""Test utilities.""" +from collections.abc import Awaitable, Callable + +from aiohttp.web import Application, Request, StreamResponse, middleware + + +def mock_real_ip(app: Application) -> Callable[[str], None]: + """Inject middleware to mock real IP. + + Returns a function to set the real IP. + """ + ip_to_mock: str | None = None + + def set_ip_to_mock(value: str): + nonlocal ip_to_mock + ip_to_mock = value + + @middleware + async def mock_real_ip( + request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] + ) -> StreamResponse: + """Mock Real IP middleware.""" + nonlocal ip_to_mock + + request = request.clone(remote=ip_to_mock) + + return await handler(request) + + async def real_ip_startup(app): + """Startup of real ip.""" + app.middlewares.insert(0, mock_real_ip) + + app.on_startup.append(real_ip_startup) + + return set_ip_to_mock From af71c2bb4571f1658c13d8370827520cf8eb014c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Nov 2023 17:34:45 +0100 Subject: [PATCH 718/982] Raise and suppress stack trace when reloading yaml fails (#102410) * Allow async_integration_yaml_config to raise * Docstr - split check * Implement as wrapper, return dataclass * Fix setup error handling * Fix reload test mock * Move log_messages to error handler * Remove unreachable code * Remove config test helper * Refactor and ensure notifications during setup * Remove redundat error, adjust tests notifications * Fix patch * Apply suggestions from code review Co-authored-by: Erik Montnemery * Follow up comments * Add call_back decorator * Split long lines * Update exception abbreviations --------- Co-authored-by: Erik Montnemery --- .../components/homeassistant/scene.py | 4 +- .../components/homeassistant/strings.json | 30 ++ homeassistant/components/lovelace/__init__.py | 9 +- homeassistant/components/mqtt/__init__.py | 20 +- homeassistant/components/template/__init__.py | 5 +- homeassistant/config.py | 418 +++++++++++---- homeassistant/exceptions.py | 25 + homeassistant/helpers/entity_component.py | 2 +- homeassistant/helpers/reload.py | 37 +- homeassistant/setup.py | 52 +- tests/common.py | 7 +- tests/helpers/test_reload.py | 49 +- tests/test_bootstrap.py | 5 +- tests/test_config.py | 480 +++++++++++++++--- tests/test_setup.py | 6 +- 15 files changed, 954 insertions(+), 195 deletions(-) diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 4b694d2b97a..258970378b2 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -194,7 +194,9 @@ async def async_setup_platform( integration = await async_get_integration(hass, SCENE_DOMAIN) - conf = await conf_util.async_process_component_config(hass, config, integration) + conf = await conf_util.async_process_component_and_handle_errors( + hass, config, integration + ) if not (conf and platform): return diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index f14d9f8148c..6981bdfe685 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -138,6 +138,36 @@ } }, "exceptions": { + "component_import_err": { + "message": "Unable to import {domain}: {error}" + }, + "config_platform_import_err": { + "message": "Error importing config platform {domain}: {error}" + }, + "config_validation_err": { + "message": "Invalid config for integration {domain} at {config_file}, line {line}: {error}. Check the logs for more information." + }, + "config_validator_unknown_err": { + "message": "Unknown error calling {domain} config validator. Check the logs for more information." + }, + "config_schema_unknown_err": { + "message": "Unknown error calling {domain} CONFIG_SCHEMA. Check the logs for more information." + }, + "integration_config_error": { + "message": "Failed to process config for integration {domain} due to multiple ({errors}) errors. Check the logs for more information." + }, + "platform_component_load_err": { + "message": "Platform error: {domain} - {error}. Check the logs for more information." + }, + "platform_component_load_exc": { + "message": "Platform error: {domain} - {error}. Check the logs for more information." + }, + "platform_config_validation_err": { + "message": "Invalid config for {domain} from integration {p_name} at file {config_file}, line {line}: {error}. Check the logs for more information." + }, + "platform_schema_validator_err": { + "message": "Unknown error when validating config for {domain} from integration {p_name}" + }, "service_not_found": { "message": "Service {domain}.{service} not found." } diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 2c425bec785..daa44bf60be 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,7 +4,10 @@ import logging import voluptuous as vol from homeassistant.components import frontend, websocket_api -from homeassistant.config import async_hass_config_yaml, async_process_component_config +from homeassistant.config import ( + async_hass_config_yaml, + async_process_component_and_handle_errors, +) from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError @@ -85,7 +88,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: integration = await async_get_integration(hass, DOMAIN) - config = await async_process_component_config(hass, conf, integration) + config = await async_process_component_and_handle_errors( + hass, conf, integration + ) if config is None: raise HomeAssistantError("Config validation failed") diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 83e6dae55b1..dd51b276715 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( - HomeAssistantError, + ConfigValidationError, ServiceValidationError, TemplateError, Unauthorized, @@ -417,14 +417,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" # Fetch updated manually configured items and validate - if ( - config_yaml := await async_integration_yaml_config(hass, DOMAIN) - ) is None: - # Raise in case we have an invalid configuration - raise HomeAssistantError( - "Error reloading manually configured MQTT items, " - "check your configuration.yaml" + try: + config_yaml = await async_integration_yaml_config( + hass, DOMAIN, raise_on_failure=True ) + except ConfigValidationError as ex: + raise ServiceValidationError( + str(ex), + translation_domain=ex.translation_domain, + translation_key=ex.translation_key, + translation_placeholders=ex.translation_placeholders, + ) from ex + # Check the schema before continuing reload await async_check_config_schema(hass, config_yaml) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 22919ac9e70..d52dc0cf166 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -34,8 +34,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error(err) return - conf = await conf_util.async_process_component_config( - hass, unprocessed_conf, await async_get_integration(hass, DOMAIN) + integration = await async_get_integration(hass, DOMAIN) + conf = await conf_util.async_process_component_and_handle_errors( + hass, unprocessed_conf, integration ) if conf is None: diff --git a/homeassistant/config.py b/homeassistant/config.py index 6a840b01714..a9c505b0a68 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Callable, Sequence from contextlib import suppress +from dataclasses import dataclass +from enum import StrEnum from functools import reduce import logging import operator @@ -12,7 +14,7 @@ from pathlib import Path import re import shutil from types import ModuleType -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from awesomeversion import AwesomeVersion @@ -54,7 +56,7 @@ from .const import ( __version__, ) from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback -from .exceptions import HomeAssistantError +from .exceptions import ConfigValidationError, HomeAssistantError from .generated.currencies import HISTORIC_CURRENCIES from .helpers import ( config_per_platform, @@ -66,13 +68,13 @@ from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements +from .setup import async_notify_setup_error from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system from .util.yaml import SECRET_YAML, Secrets, load_yaml _LOGGER = logging.getLogger(__name__) -DATA_PERSISTENT_ERRORS = "bootstrap_persistent_errors" RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") RE_ASCII = re.compile(r"\033\[[^m]*m") YAML_CONFIG_FILE = "configuration.yaml" @@ -117,6 +119,46 @@ tts: """ +class ConfigErrorTranslationKey(StrEnum): + """Config error translation keys for config errors.""" + + # translation keys with a generated config related message text + CONFIG_VALIDATION_ERR = "config_validation_err" + PLATFORM_CONFIG_VALIDATION_ERR = "platform_config_validation_err" + + # translation keys with a general static message text + COMPONENT_IMPORT_ERR = "component_import_err" + CONFIG_PLATFORM_IMPORT_ERR = "config_platform_import_err" + CONFIG_VALIDATOR_UNKNOWN_ERR = "config_validator_unknown_err" + CONFIG_SCHEMA_UNKNOWN_ERR = "config_schema_unknown_err" + PLATFORM_VALIDATOR_UNKNOWN_ERR = "platform_validator_unknown_err" + PLATFORM_COMPONENT_LOAD_ERR = "platform_component_load_err" + PLATFORM_COMPONENT_LOAD_EXC = "platform_component_load_exc" + PLATFORM_SCHEMA_VALIDATOR_ERR = "platform_schema_validator_err" + + # translation key in case multiple errors occurred + INTEGRATION_CONFIG_ERROR = "integration_config_error" + + +@dataclass +class ConfigExceptionInfo: + """Configuration exception info class.""" + + exception: Exception + translation_key: ConfigErrorTranslationKey + platform_name: str + config: ConfigType + integration_link: str | None + + +@dataclass +class IntegrationConfigInfo: + """Configuration for an integration and exception information.""" + + config: ConfigType | None + exception_info_list: list[ConfigExceptionInfo] + + def _no_duplicate_auth_provider( configs: Sequence[dict[str, Any]] ) -> Sequence[dict[str, Any]]: @@ -1025,21 +1067,193 @@ async def merge_packages_config( return config -async def async_process_component_config( # noqa: C901 - hass: HomeAssistant, config: ConfigType, integration: Integration -) -> ConfigType | None: - """Check component configuration and return processed configuration. +@callback +def _get_log_message_and_stack_print_pref( + hass: HomeAssistant, domain: str, platform_exception: ConfigExceptionInfo +) -> tuple[str | None, bool, dict[str, str]]: + """Get message to log and print stack trace preference.""" + exception = platform_exception.exception + platform_name = platform_exception.platform_name + platform_config = platform_exception.config + link = platform_exception.integration_link - Returns None on error. + placeholders: dict[str, str] = {"domain": domain, "error": str(exception)} + + log_message_mapping: dict[ConfigErrorTranslationKey, tuple[str, bool]] = { + ConfigErrorTranslationKey.COMPONENT_IMPORT_ERR: ( + f"Unable to import {domain}: {exception}", + False, + ), + ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR: ( + f"Error importing config platform {domain}: {exception}", + False, + ), + ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR: ( + f"Unknown error calling {domain} config validator", + True, + ), + ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR: ( + f"Unknown error calling {domain} CONFIG_SCHEMA", + True, + ), + ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: ( + f"Unknown error validating {platform_name} platform config with {domain} " + "component platform schema", + True, + ), + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR: ( + f"Platform error: {domain} - {exception}", + False, + ), + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC: ( + f"Platform error: {domain} - {exception}", + True, + ), + ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR: ( + f"Unknown error validating config for {platform_name} platform " + f"for {domain} component with PLATFORM_SCHEMA", + True, + ), + } + log_message_show_stack_trace = log_message_mapping.get( + platform_exception.translation_key + ) + if log_message_show_stack_trace is None: + # If no pre defined log_message is set, we generate an enriched error + # message, so we can notify about it during setup + show_stack_trace = False + if isinstance(exception, vol.Invalid): + log_message = format_schema_error( + hass, exception, platform_name, platform_config, link + ) + if annotation := find_annotation(platform_config, exception.path): + placeholders["config_file"], line = annotation + placeholders["line"] = str(line) + else: + if TYPE_CHECKING: + assert isinstance(exception, HomeAssistantError) + log_message = format_homeassistant_error( + hass, exception, platform_name, platform_config, link + ) + if annotation := find_annotation(platform_config, [platform_name]): + placeholders["config_file"], line = annotation + placeholders["line"] = str(line) + show_stack_trace = True + return (log_message, show_stack_trace, placeholders) + + assert isinstance(log_message_show_stack_trace, tuple) + + return (*log_message_show_stack_trace, placeholders) + + +async def async_process_component_and_handle_errors( + hass: HomeAssistant, + config: ConfigType, + integration: Integration, + raise_on_failure: bool = False, +) -> ConfigType | None: + """Process and component configuration and handle errors. + + In case of errors: + - Print the error messages to the log. + - Raise a ConfigValidationError if raise_on_failure is set. + + Returns the integration config or `None`. + """ + integration_config_info = await async_process_component_config( + hass, config, integration + ) + return async_handle_component_errors( + hass, integration_config_info, integration, raise_on_failure + ) + + +@callback +def async_handle_component_errors( + hass: HomeAssistant, + integration_config_info: IntegrationConfigInfo, + integration: Integration, + raise_on_failure: bool = False, +) -> ConfigType | None: + """Handle component configuration errors from async_process_component_config. + + In case of errors: + - Print the error messages to the log. + - Raise a ConfigValidationError if raise_on_failure is set. + + Returns the integration config or `None`. + """ + + if not (config_exception_info := integration_config_info.exception_info_list): + return integration_config_info.config + + platform_exception: ConfigExceptionInfo + domain = integration.domain + placeholders: dict[str, str] + for platform_exception in config_exception_info: + exception = platform_exception.exception + ( + log_message, + show_stack_trace, + placeholders, + ) = _get_log_message_and_stack_print_pref(hass, domain, platform_exception) + _LOGGER.error( + log_message, + exc_info=exception if show_stack_trace else None, + ) + + if not raise_on_failure: + return integration_config_info.config + + if len(config_exception_info) == 1: + translation_key = platform_exception.translation_key + else: + translation_key = ConfigErrorTranslationKey.INTEGRATION_CONFIG_ERROR + errors = str(len(config_exception_info)) + log_message = ( + f"Failed to process component config for integration {domain} " + f"due to multiple errors ({errors}), check the logs for more information." + ) + placeholders = { + "domain": domain, + "errors": errors, + } + raise ConfigValidationError( + str(log_message), + [platform_exception.exception for platform_exception in config_exception_info], + translation_domain="homeassistant", + translation_key=translation_key, + translation_placeholders=placeholders, + ) + + +async def async_process_component_config( # noqa: C901 + hass: HomeAssistant, + config: ConfigType, + integration: Integration, +) -> IntegrationConfigInfo: + """Check component configuration. + + Returns processed configuration and exception information. This method must be run in the event loop. """ domain = integration.domain + integration_docs = integration.documentation + config_exceptions: list[ConfigExceptionInfo] = [] + try: component = integration.get_component() - except LOAD_EXCEPTIONS as ex: - _LOGGER.error("Unable to import %s: %s", domain, ex) - return None + except LOAD_EXCEPTIONS as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.COMPONENT_IMPORT_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) # Check if the integration has a custom config validator config_validator = None @@ -1050,62 +1264,101 @@ async def async_process_component_config( # noqa: C901 # If the config platform contains bad imports, make sure # that still fails. if err.name != f"{integration.pkg_path}.config": - _LOGGER.error("Error importing config platform %s: %s", domain, err) - return None + exc_info = ConfigExceptionInfo( + err, + ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) if config_validator is not None and hasattr( config_validator, "async_validate_config" ): try: - return ( # type: ignore[no-any-return] - await config_validator.async_validate_config(hass, config) + return IntegrationConfigInfo( + await config_validator.async_validate_config(hass, config), [] ) - except (vol.Invalid, HomeAssistantError) as ex: - async_log_config_validator_error( - ex, domain, config, hass, integration.documentation + except (vol.Invalid, HomeAssistantError) as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR, + domain, + config, + integration_docs, ) - return None - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error calling %s config validator", domain) - return None + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) # No custom config validator, proceed with schema validation if hasattr(component, "CONFIG_SCHEMA"): try: - return component.CONFIG_SCHEMA(config) # type: ignore[no-any-return] - except vol.Invalid as ex: - async_log_schema_error(ex, domain, config, hass, integration.documentation) - return None - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error calling %s CONFIG_SCHEMA", domain) - return None + return IntegrationConfigInfo(component.CONFIG_SCHEMA(config), []) + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) component_platform_schema = getattr( component, "PLATFORM_SCHEMA_BASE", getattr(component, "PLATFORM_SCHEMA", None) ) if component_platform_schema is None: - return config + return IntegrationConfigInfo(config, []) - platforms = [] + platforms: list[ConfigType] = [] for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema + platform_name = f"{domain}.{p_name}" try: p_validated = component_platform_schema(p_config) - except vol.Invalid as ex: - async_log_schema_error( - ex, domain, p_config, hass, integration.documentation - ) - continue - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - ( - "Unknown error validating %s platform config with %s component" - " platform schema" - ), - p_name, + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, domain, + p_config, + integration_docs, ) + config_exceptions.append(exc_info) + continue + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, + str(p_name), + config, + integration_docs, + ) + config_exceptions.append(exc_info) continue # Not all platform components follow same pattern for platforms @@ -1117,38 +1370,53 @@ async def async_process_component_config( # noqa: C901 try: p_integration = await async_get_integration_with_requirements(hass, p_name) - except (RequirementsNotFound, IntegrationNotFound) as ex: - _LOGGER.error("Platform error: %s - %s", domain, ex) + except (RequirementsNotFound, IntegrationNotFound) as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, + platform_name, + p_config, + integration_docs, + ) + config_exceptions.append(exc_info) continue try: platform = p_integration.get_platform(domain) - except LOAD_EXCEPTIONS: - _LOGGER.exception("Platform error: %s", domain) + except LOAD_EXCEPTIONS as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, + platform_name, + p_config, + integration_docs, + ) + config_exceptions.append(exc_info) continue # Validate platform specific schema if hasattr(platform, "PLATFORM_SCHEMA"): try: p_validated = platform.PLATFORM_SCHEMA(p_config) - except vol.Invalid as ex: - async_log_schema_error( - ex, - f"{domain}.{p_name}", + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, + platform_name, p_config, - hass, p_integration.documentation, ) + config_exceptions.append(exc_info) continue - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - ( - "Unknown error validating config for %s platform for %s" - " component with PLATFORM_SCHEMA" - ), + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, p_name, - domain, + p_config, + p_integration.documentation, ) + config_exceptions.append(exc_info) continue platforms.append(p_validated) @@ -1158,7 +1426,7 @@ async def async_process_component_config( # noqa: C901 config = config_without_domain(config, domain) config[domain] = platforms - return config + return IntegrationConfigInfo(config, config_exceptions) @callback @@ -1183,36 +1451,6 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> str | None: return res.error_str -@callback -def async_notify_setup_error( - hass: HomeAssistant, component: str, display_link: str | None = None -) -> None: - """Print a persistent notification. - - This method must be run in the event loop. - """ - # pylint: disable-next=import-outside-toplevel - from .components import persistent_notification - - if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: - errors = hass.data[DATA_PERSISTENT_ERRORS] = {} - - errors[component] = errors.get(component) or display_link - - message = "The following integrations and platforms could not be set up:\n\n" - - for name, link in errors.items(): - show_logs = f"[Show logs](/config/logs?filter={name})" - part = f"[{name}]({link})" if link else name - message += f" - {part} ({show_logs})\n" - - message += "\nPlease check your config and [logs](/config/logs)." - - persistent_notification.async_create( - hass, message, "Invalid config", "invalid_config" - ) - - def safe_mode_enabled(config_dir: str) -> bool: """Return if safe mode is enabled. diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 262b0e338ff..8d5e2bbde95 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -26,6 +26,31 @@ class HomeAssistantError(Exception): self.translation_placeholders = translation_placeholders +class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]): + """A validation exception occurred when validating the configuration.""" + + def __init__( + self, + message: str, + exceptions: list[Exception], + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + """Initialize exception.""" + super().__init__( + *(message, exceptions), + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + self._message = message + + def __str__(self) -> str: + """Return exception message string.""" + return self._message + + class ServiceValidationError(HomeAssistantError): """A validation exception occurred when calling a service.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ddd46759259..775d0934c36 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -355,7 +355,7 @@ class EntityComponent(Generic[_EntityT]): integration = await async_get_integration(self.hass, self.domain) - processed_conf = await conf_util.async_process_component_config( + processed_conf = await conf_util.async_process_component_and_handle_errors( self.hass, conf, integration ) diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 6e719cdac24..42ebc2d0869 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable import logging -from typing import Any +from typing import Any, Literal, overload from homeassistant import config as conf_util from homeassistant.const import SERVICE_RELOAD @@ -60,7 +60,7 @@ async def _resetup_platform( """Resetup a platform.""" integration = await async_get_integration(hass, platform_domain) - conf = await conf_util.async_process_component_config( + conf = await conf_util.async_process_component_and_handle_errors( hass, unprocessed_config, integration ) @@ -136,14 +136,41 @@ async def _async_reconfig_platform( await asyncio.gather(*tasks) +@overload async def async_integration_yaml_config( hass: HomeAssistant, integration_name: str +) -> ConfigType | None: + ... + + +@overload +async def async_integration_yaml_config( + hass: HomeAssistant, + integration_name: str, + *, + raise_on_failure: Literal[True], +) -> ConfigType: + ... + + +@overload +async def async_integration_yaml_config( + hass: HomeAssistant, + integration_name: str, + *, + raise_on_failure: Literal[False] | bool, +) -> ConfigType | None: + ... + + +async def async_integration_yaml_config( + hass: HomeAssistant, integration_name: str, *, raise_on_failure: bool = False ) -> ConfigType | None: """Fetch the latest yaml configuration for an integration.""" integration = await async_get_integration(hass, integration_name) - - return await conf_util.async_process_component_config( - hass, await conf_util.async_hass_config_yaml(hass), integration + config = await conf_util.async_hass_config_yaml(hass) + return await conf_util.async_process_component_and_handle_errors( + hass, config, integration, raise_on_failure=raise_on_failure ) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 9b705b4735e..679042bc4e9 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -11,14 +11,13 @@ from types import ModuleType from typing import Any from . import config as conf_util, core, loader, requirements -from .config import async_notify_setup_error from .const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, PLATFORM_FORMAT, Platform, ) -from .core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN +from .core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from .exceptions import DependencyError, HomeAssistantError from .helpers.issue_registry import IssueSeverity, async_create_issue from .helpers.typing import ConfigType @@ -56,10 +55,47 @@ DATA_SETUP_TIME = "setup_time" DATA_DEPS_REQS = "deps_reqs_processed" +DATA_PERSISTENT_ERRORS = "bootstrap_persistent_errors" + +NOTIFY_FOR_TRANSLATION_KEYS = [ + "config_validation_err", + "platform_config_validation_err", +] + SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 300 +@callback +def async_notify_setup_error( + hass: HomeAssistant, component: str, display_link: str | None = None +) -> None: + """Print a persistent notification. + + This method must be run in the event loop. + """ + # pylint: disable-next=import-outside-toplevel + from .components import persistent_notification + + if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: + errors = hass.data[DATA_PERSISTENT_ERRORS] = {} + + errors[component] = errors.get(component) or display_link + + message = "The following integrations and platforms could not be set up:\n\n" + + for name, link in errors.items(): + show_logs = f"[Show logs](/config/logs?filter={name})" + part = f"[{name}]({link})" if link else name + message += f" - {part} ({show_logs})\n" + + message += "\nPlease check your config and [logs](/config/logs)." + + persistent_notification.async_create( + hass, message, "Invalid config", "invalid_config" + ) + + @core.callback def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) -> None: """Set domains that are going to be loaded from the config. @@ -217,10 +253,18 @@ async def _async_setup_component( log_error(f"Unable to import component: {err}", err) return False - processed_config = await conf_util.async_process_component_config( + integration_config_info = await conf_util.async_process_component_config( hass, config, integration ) - + processed_config = conf_util.async_handle_component_errors( + hass, integration_config_info, integration + ) + for platform_exception in integration_config_info.exception_info_list: + if platform_exception.translation_key not in NOTIFY_FOR_TRANSLATION_KEYS: + continue + async_notify_setup_error( + hass, platform_exception.platform_name, platform_exception.integration_link + ) if processed_config is None: log_error("Invalid config.") return False diff --git a/tests/common.py b/tests/common.py index bc770fae2fe..06cdee06ec9 100644 --- a/tests/common.py +++ b/tests/common.py @@ -984,7 +984,10 @@ def assert_setup_component(count, domain=None): async def mock_psc(hass, config_input, integration): """Mock the prepare_setup_component to capture config.""" domain_input = integration.domain - res = await async_process_component_config(hass, config_input, integration) + integration_config_info = await async_process_component_config( + hass, config_input, integration + ) + res = integration_config_info.config config[domain_input] = None if res is None else res.get(domain_input) _LOGGER.debug( "Configuration for %s, Validated: %s, Original %s", @@ -992,7 +995,7 @@ def assert_setup_component(count, domain=None): config[domain_input], config_input.get(domain_input), ) - return res + return integration_config_info assert isinstance(config, dict) with patch("homeassistant.config.async_process_component_config", mock_psc): diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index 9c3789a3553..586dbc19eb8 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -3,10 +3,12 @@ import logging from unittest.mock import AsyncMock, Mock, patch import pytest +import voluptuous as vol from homeassistant import config from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigValidationError, HomeAssistantError from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import ( @@ -139,7 +141,9 @@ async def test_setup_reload_service_when_async_process_component_config_fails( yaml_path = get_fixture_path("helpers/reload_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path), patch.object( - config, "async_process_component_config", return_value=None + config, + "async_process_component_config", + return_value=config.IntegrationConfigInfo(None, []), ): await hass.services.async_call( PLATFORM, @@ -208,8 +212,49 @@ async def test_async_integration_yaml_config(hass: HomeAssistant) -> None: yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path): processed_config = await async_integration_yaml_config(hass, DOMAIN) + assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} + # Test fetching yaml config does not raise when the raise_on_failure option is set + processed_config = await async_integration_yaml_config( + hass, DOMAIN, raise_on_failure=True + ) + assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} - assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} + +async def test_async_integration_failing_yaml_config(hass: HomeAssistant) -> None: + """Test reloading yaml config for an integration fails. + + In case an integration reloads its yaml configuration it should throw when + the new config failed to load and raise_on_failure is set to True. + """ + schema_without_name_attr = vol.Schema({vol.Required("some_option"): str}) + + mock_integration(hass, MockModule(DOMAIN, config_schema=schema_without_name_attr)) + + yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml") + with patch.object(config, "YAML_CONFIG_FILE", yaml_path): + # Test fetching yaml config does not raise without raise_on_failure option + processed_config = await async_integration_yaml_config(hass, DOMAIN) + assert processed_config is None + # Test fetching yaml config does not raise when the raise_on_failure option is set + with pytest.raises(ConfigValidationError): + await async_integration_yaml_config(hass, DOMAIN, raise_on_failure=True) + + +async def test_async_integration_failing_on_reload(hass: HomeAssistant) -> None: + """Test reloading yaml config for an integration fails with an other exception. + + In case an integration reloads its yaml configuration it should throw when + the new config failed to load and raise_on_failure is set to True. + """ + mock_integration(hass, MockModule(DOMAIN)) + + yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml") + with patch.object(config, "YAML_CONFIG_FILE", yaml_path), patch( + "homeassistant.config.async_process_component_config", + side_effect=HomeAssistantError(), + ), pytest.raises(HomeAssistantError): + # Test fetching yaml config does raise when the raise_on_failure option is set + await async_integration_yaml_config(hass, DOMAIN, raise_on_failure=True) async def test_async_integration_missing_yaml_config(hass: HomeAssistant) -> None: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c3e25219369..f6d3b92bb4a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1013,7 +1013,10 @@ async def test_bootstrap_dependencies( with patch( "homeassistant.setup.loader.async_get_integrations", side_effect=mock_async_get_integrations, - ), patch("homeassistant.config.async_process_component_config", return_value={}): + ), patch( + "homeassistant.config.async_process_component_config", + return_value=config_util.IntegrationConfigInfo({}, []), + ): bootstrap.async_set_domains_to_be_loaded(hass, {integration}) await bootstrap.async_setup_multi_components(hass, {integration}, {}) await hass.async_block_till_done() diff --git a/tests/test_config.py b/tests/test_config.py index 448990429a1..de5e7e0581d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -30,6 +30,7 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import ConfigSource, HomeAssistant, HomeAssistantError +from homeassistant.exceptions import ConfigValidationError from homeassistant.helpers import config_validation as cv, issue_registry as ir import homeassistant.helpers.check_config as check_config from homeassistant.helpers.entity import Entity @@ -1427,71 +1428,132 @@ async def test_component_config_exceptions( ) -> None: """Test unexpected exceptions validating component config.""" # Config validator + test_integration = Mock( + domain="test_domain", + get_platform=Mock( + return_value=Mock( + async_validate_config=AsyncMock(side_effect=ValueError("broken")) + ) + ), + ) assert ( - await config_util.async_process_component_config( - hass, - {}, - integration=Mock( - domain="test_domain", - get_platform=Mock( - return_value=Mock( - async_validate_config=AsyncMock( - side_effect=ValueError("broken") - ) - ) - ), - ), + await config_util.async_process_component_and_handle_errors( + hass, {}, integration=test_integration ) is None ) assert "ValueError: broken" in caplog.text assert "Unknown error calling test_domain config validator" in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, {}, integration=test_integration, raise_on_failure=True + ) + assert "ValueError: broken" in caplog.text + assert "Unknown error calling test_domain config validator" in caplog.text + assert str(ex.value) == "Unknown error calling test_domain config validator" - # component.CONFIG_SCHEMA + test_integration = Mock( + domain="test_domain", + get_platform=Mock( + return_value=Mock( + async_validate_config=AsyncMock( + side_effect=HomeAssistantError("broken") + ) + ) + ), + get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])), + ) caplog.clear() assert ( - await config_util.async_process_component_config( - hass, - {}, - integration=Mock( - domain="test_domain", - get_platform=Mock(return_value=None), - get_component=Mock( - return_value=Mock( - CONFIG_SCHEMA=Mock(side_effect=ValueError("broken")) - ) - ), - ), + await config_util.async_process_component_and_handle_errors( + hass, {}, integration=test_integration, raise_on_failure=False + ) + is None + ) + assert "Invalid config for 'test_domain': broken" in caplog.text + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, {}, integration=test_integration, raise_on_failure=True + ) + assert "Invalid config for 'test_domain': broken" in str(ex.value) + + # component.CONFIG_SCHEMA + caplog.clear() + test_integration = Mock( + domain="test_domain", + get_platform=Mock(return_value=None), + get_component=Mock( + return_value=Mock(CONFIG_SCHEMA=Mock(side_effect=ValueError("broken"))) + ), + ) + assert ( + await config_util.async_process_component_and_handle_errors( + hass, + {}, + integration=test_integration, + raise_on_failure=False, ) is None ) - assert "ValueError: broken" in caplog.text assert "Unknown error calling test_domain CONFIG_SCHEMA" in caplog.text + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, + {}, + integration=test_integration, + raise_on_failure=True, + ) + assert "Unknown error calling test_domain CONFIG_SCHEMA" in caplog.text + assert str(ex.value) == "Unknown error calling test_domain CONFIG_SCHEMA" # component.PLATFORM_SCHEMA caplog.clear() - assert await config_util.async_process_component_config( + test_integration = Mock( + domain="test_domain", + get_platform=Mock(return_value=None), + get_component=Mock( + return_value=Mock( + spec=["PLATFORM_SCHEMA_BASE"], + PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")), + ) + ), + ) + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, - integration=Mock( - domain="test_domain", - get_platform=Mock(return_value=None), - get_component=Mock( - return_value=Mock( - spec=["PLATFORM_SCHEMA_BASE"], - PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")), - ) - ), - ), + integration=test_integration, + raise_on_failure=False, ) == {"test_domain": []} assert "ValueError: broken" in caplog.text assert ( - "Unknown error validating test_platform platform config " - "with test_domain component platform schema" + "Unknown error validating config for test_platform platform " + "for test_domain component with PLATFORM_SCHEMA" ) in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Unknown error validating config for test_platform platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + assert str(ex.value) == ( + "Unknown error validating config for test_platform platform " + "for test_domain component with PLATFORM_SCHEMA" + ) # platform.PLATFORM_SCHEMA caplog.clear() + test_integration = Mock( + domain="test_domain", + get_platform=Mock(return_value=None), + get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])), + ) with patch( "homeassistant.config.async_get_integration_with_requirements", return_value=Mock( # integration that owns platform @@ -1502,67 +1564,337 @@ async def test_component_config_exceptions( ) ), ): - assert await config_util.async_process_component_config( + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, - integration=Mock( - domain="test_domain", - get_platform=Mock(return_value=None), - get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])), - ), + integration=test_integration, + raise_on_failure=False, ) == {"test_domain": []} assert "ValueError: broken" in caplog.text + assert ( + "Unknown error validating config for test_platform platform for test_domain" + " component with PLATFORM_SCHEMA" + ) in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + assert await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Unknown error validating config for test_platform platform for test_domain" + " component with PLATFORM_SCHEMA" + ) in str(ex.value) + assert "ValueError: broken" in caplog.text assert ( "Unknown error validating config for test_platform platform for test_domain" " component with PLATFORM_SCHEMA" in caplog.text ) + # Test multiple platform failures + assert await config_util.async_process_component_and_handle_errors( + hass, + { + "test_domain": [ + {"platform": "test_platform1"}, + {"platform": "test_platform2"}, + ] + }, + integration=test_integration, + raise_on_failure=False, + ) == {"test_domain": []} + assert "ValueError: broken" in caplog.text + assert ( + "Unknown error validating config for test_platform1 platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + assert ( + "Unknown error validating config for test_platform2 platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + assert await config_util.async_process_component_and_handle_errors( + hass, + { + "test_domain": [ + {"platform": "test_platform1"}, + {"platform": "test_platform2"}, + ] + }, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Failed to process component config for integration test_domain" + " due to multiple errors (2), check the logs for more information." + ) in str(ex.value) + assert "ValueError: broken" in caplog.text + assert ( + "Unknown error validating config for test_platform1 platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + assert ( + "Unknown error validating config for test_platform2 platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + + # get_platform("domain") raising on ImportError + caplog.clear() + test_integration = Mock( + domain="test_domain", + get_platform=Mock(return_value=None), + get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])), + ) + import_error = ImportError( + ("ModuleNotFoundError: No module named 'not_installed_something'"), + name="not_installed_something", + ) + with patch( + "homeassistant.config.async_get_integration_with_requirements", + return_value=Mock( # integration that owns platform + get_platform=Mock(side_effect=import_error) + ), + ): + assert await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=False, + ) == {"test_domain": []} + assert ( + "ImportError: ModuleNotFoundError: No module named " + "'not_installed_something'" in caplog.text + ) + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + assert await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "ImportError: ModuleNotFoundError: No module named " + "'not_installed_something'" in caplog.text + ) + assert ( + "Platform error: test_domain - ModuleNotFoundError: " + "No module named 'not_installed_something'" + ) in caplog.text + assert ( + "Platform error: test_domain - ModuleNotFoundError: " + "No module named 'not_installed_something'" + ) in str(ex.value) # get_platform("config") raising caplog.clear() + test_integration = Mock( + pkg_path="homeassistant.components.test_domain", + domain="test_domain", + get_platform=Mock( + side_effect=ImportError( + ("ModuleNotFoundError: No module named 'not_installed_something'"), + name="not_installed_something", + ) + ), + ) assert ( - await config_util.async_process_component_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {}}, - integration=Mock( - pkg_path="homeassistant.components.test_domain", - domain="test_domain", - get_platform=Mock( - side_effect=ImportError( - ( - "ModuleNotFoundError: No module named" - " 'not_installed_something'" - ), - name="not_installed_something", - ) - ), - ), + integration=test_integration, + raise_on_failure=False, ) is None ) assert ( - "Error importing config platform test_domain: ModuleNotFoundError: No module" - " named 'not_installed_something'" in caplog.text + "Error importing config platform test_domain: ModuleNotFoundError: " + "No module named 'not_installed_something'" in caplog.text + ) + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Error importing config platform test_domain: ModuleNotFoundError: " + "No module named 'not_installed_something'" in caplog.text + ) + assert ( + "Error importing config platform test_domain: ModuleNotFoundError: " + "No module named 'not_installed_something'" in str(ex.value) ) # get_component raising caplog.clear() + test_integration = Mock( + pkg_path="homeassistant.components.test_domain", + domain="test_domain", + get_component=Mock( + side_effect=FileNotFoundError("No such file or directory: b'liblibc.a'") + ), + ) assert ( - await config_util.async_process_component_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {}}, - integration=Mock( - pkg_path="homeassistant.components.test_domain", - domain="test_domain", - get_component=Mock( - side_effect=FileNotFoundError( - "No such file or directory: b'liblibc.a'" - ) - ), - ), + integration=test_integration, + raise_on_failure=False, ) is None ) assert "Unable to import test_domain: No such file or directory" in caplog.text + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {}}, + integration=test_integration, + raise_on_failure=True, + ) + assert "Unable to import test_domain: No such file or directory" in caplog.text + assert "Unable to import test_domain: No such file or directory" in str(ex.value) + + +@pytest.mark.parametrize( + ("exception_info_list", "error", "messages", "show_stack_trace", "translation_key"), + [ + ( + [ + config_util.ConfigExceptionInfo( + ImportError("bla"), + "component_import_err", + "test_domain", + {"test_domain": []}, + "https://example.com", + ) + ], + "bla", + ["Unable to import test_domain: bla", "bla"], + False, + "component_import_err", + ), + ( + [ + config_util.ConfigExceptionInfo( + HomeAssistantError("bla"), + "config_validation_err", + "test_domain", + {"test_domain": []}, + "https://example.com", + ) + ], + "bla", + [ + "Invalid config for 'test_domain': bla, " + "please check the docs at https://example.com", + "bla", + ], + True, + "config_validation_err", + ), + ( + [ + config_util.ConfigExceptionInfo( + vol.Invalid("bla", ["path"]), + "config_validation_err", + "test_domain", + {"test_domain": []}, + "https://example.com", + ) + ], + "bla @ data['path']", + [ + "Invalid config for 'test_domain': bla 'path', got None, " + "please check the docs at https://example.com", + "bla", + ], + False, + "config_validation_err", + ), + ( + [ + config_util.ConfigExceptionInfo( + vol.Invalid("bla", ["path"]), + "platform_config_validation_err", + "test_domain", + {"test_domain": []}, + "https://alt.example.com", + ) + ], + "bla @ data['path']", + [ + "Invalid config for 'test_domain': bla 'path', got None, " + "please check the docs at https://alt.example.com", + "bla", + ], + False, + "platform_config_validation_err", + ), + ( + [ + config_util.ConfigExceptionInfo( + ImportError("bla"), + "platform_component_load_err", + "test_domain", + {"test_domain": []}, + "https://example.com", + ) + ], + "bla", + ["Platform error: test_domain - bla", "bla"], + False, + "platform_component_load_err", + ), + ], +) +async def test_component_config_error_processing( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + error: str, + exception_info_list: list[config_util.ConfigExceptionInfo], + messages: list[str], + show_stack_trace: bool, + translation_key: str, +) -> None: + """Test component config error processing.""" + test_integration = Mock( + domain="test_domain", + documentation="https://example.com", + get_platform=Mock( + return_value=Mock( + async_validate_config=AsyncMock(side_effect=ValueError("broken")) + ) + ), + ) + with patch( + "homeassistant.config.async_process_component_config", + return_value=config_util.IntegrationConfigInfo(None, exception_info_list), + ), pytest.raises(ConfigValidationError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, {}, test_integration, raise_on_failure=True + ) + records = [record for record in caplog.records if record.msg == messages[0]] + assert len(records) == 1 + assert (records[0].exc_info is not None) == show_stack_trace + assert str(ex.value) == messages[0] + assert ex.value.translation_key == translation_key + assert ex.value.translation_domain == "homeassistant" + assert ex.value.translation_placeholders["domain"] == "test_domain" + assert all(message in caplog.text for message in messages) + + caplog.clear() + with patch( + "homeassistant.config.async_process_component_config", + return_value=config_util.IntegrationConfigInfo(None, exception_info_list), + ): + await config_util.async_process_component_and_handle_errors( + hass, {}, test_integration + ) + assert all(message in caplog.text for message in messages) @pytest.mark.parametrize( @@ -1713,7 +2045,7 @@ async def test_component_config_validation_error( integration = await async_get_integration( hass, domain_with_label.partition(" ")[0] ) - await config_util.async_process_component_config( + await config_util.async_process_component_and_handle_errors( hass, config, integration=integration, @@ -1758,7 +2090,7 @@ async def test_component_config_validation_error_with_docs( integration = await async_get_integration( hass, domain_with_label.partition(" ")[0] ) - await config_util.async_process_component_config( + await config_util.async_process_component_and_handle_errors( hass, config, integration=integration, diff --git a/tests/test_setup.py b/tests/test_setup.py index 0f480198c11..00bb3fa2a2d 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -374,7 +374,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: ) with assert_setup_component(0, "switch"), patch( - "homeassistant.config.async_notify_setup_error" + "homeassistant.setup.async_notify_setup_error" ) as mock_notify: assert await setup.async_setup_component( hass, @@ -389,7 +389,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: hass.config.components.remove("switch") with assert_setup_component(0), patch( - "homeassistant.config.async_notify_setup_error" + "homeassistant.setup.async_notify_setup_error" ) as mock_notify: assert await setup.async_setup_component( hass, @@ -410,7 +410,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: hass.config.components.remove("switch") with assert_setup_component(1, "switch"), patch( - "homeassistant.config.async_notify_setup_error" + "homeassistant.setup.async_notify_setup_error" ) as mock_notify: assert await setup.async_setup_component( hass, From b14a7edb559c8962a24c2831b0d3a4e37860f0e9 Mon Sep 17 00:00:00 2001 From: Marc-Olivier Arsenault Date: Fri, 24 Nov 2023 12:32:20 -0500 Subject: [PATCH 719/982] Add compWaterHeater to ecobee HVAC actions (#103278) --- homeassistant/components/ecobee/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index e1253b585ac..1b0e65f7390 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -99,6 +99,7 @@ ECOBEE_HVAC_ACTION_TO_HASS = { "economizer": HVACAction.FAN, "compHotWater": None, "auxHotWater": None, + "compWaterHeater": None, } PRESET_TO_ECOBEE_HOLD = { From 95cfe41f8727bfd262b4d73d681bb316ea8412a1 Mon Sep 17 00:00:00 2001 From: disforw Date: Fri, 24 Nov 2023 12:53:59 -0500 Subject: [PATCH 720/982] Add toggle switch to Daikin HVAC units (#95954) Co-authored-by: Franck Nijhof --- homeassistant/components/daikin/switch.py | 35 ++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 8dd75916685..7acd234e397 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -13,8 +13,10 @@ from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi ZONE_ICON = "mdi:home-circle" STREAMER_ICON = "mdi:air-filter" +TOGGLE_ICON = "mdi:power" DAIKIN_ATTR_ADVANCED = "adv" DAIKIN_ATTR_STREAMER = "streamer" +DAIKIN_ATTR_MODE = "mode" async def async_setup_platform( @@ -35,7 +37,7 @@ async def async_setup_entry( ) -> None: """Set up Daikin climate based on config_entry.""" daikin_api: DaikinApi = hass.data[DAIKIN_DOMAIN][entry.entry_id] - switches: list[DaikinZoneSwitch | DaikinStreamerSwitch] = [] + switches: list[DaikinZoneSwitch | DaikinStreamerSwitch | DaikinToggleSwitch] = [] if zones := daikin_api.device.zones: switches.extend( [ @@ -49,6 +51,7 @@ async def async_setup_entry( # device supports the streamer, so assume so if it does support # advanced modes. switches.append(DaikinStreamerSwitch(daikin_api)) + switches.append(DaikinToggleSwitch(daikin_api)) async_add_entities(switches) @@ -119,3 +122,33 @@ class DaikinStreamerSwitch(SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" await self._api.device.set_streamer("off") + + +class DaikinToggleSwitch(SwitchEntity): + """Switch state.""" + + _attr_icon = TOGGLE_ICON + _attr_has_entity_name = True + + def __init__(self, api: DaikinApi) -> None: + """Initialize switch.""" + self._api = api + self._attr_device_info = api.device_info + self._attr_unique_id = f"{self._api.device.mac}-toggle" + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return "off" not in self._api.device.represent(DAIKIN_ATTR_MODE) + + async def async_update(self) -> None: + """Retrieve latest state.""" + await self._api.async_update() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the zone on.""" + await self._api.device.set({}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the zone off.""" + await self._api.device.set({DAIKIN_ATTR_MODE: "off"}) From 724352d55caccd01f00c15ccd4e71664c9de0b7b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 24 Nov 2023 18:56:58 +0100 Subject: [PATCH 721/982] Use AsyncMock and fixtures in co2signal tests (#104041) --- .../components/co2signal/config_flow.py | 24 +++---- .../components/co2signal/coordinator.py | 17 +++-- tests/components/co2signal/__init__.py | 25 ++++--- tests/components/co2signal/conftest.py | 52 ++++++++++++++ .../components/co2signal/test_config_flow.py | 68 +++++++++---------- .../components/co2signal/test_diagnostics.py | 21 +----- 6 files changed, 123 insertions(+), 84 deletions(-) create mode 100644 tests/components/co2signal/conftest.py diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 85f437581ac..4f445238a06 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -120,18 +120,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} session = async_get_clientsession(self.hass) - async with ElectricityMaps(token=data[CONF_API_KEY], session=session) as em: - try: - await fetch_latest_carbon_intensity(self.hass, em, data) - except InvalidToken: - errors["base"] = "invalid_auth" - except ElectricityMapsError: - errors["base"] = "unknown" - else: - return self.async_create_entry( - title=get_extra_name(data) or "CO2 Signal", - data=data, - ) + em = ElectricityMaps(token=data[CONF_API_KEY], session=session) + try: + await fetch_latest_carbon_intensity(self.hass, em, data) + except InvalidToken: + errors["base"] = "invalid_auth" + except ElectricityMapsError: + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=get_extra_name(data) or "CO2 Signal", + data=data, + ) return self.async_show_form( step_id=step_id, diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 1f4abf278c0..7c0fe72e60a 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -39,12 +39,11 @@ class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): async def _async_update_data(self) -> CarbonIntensityResponse: """Fetch the latest data from the source.""" - async with self.client as em: - try: - return await fetch_latest_carbon_intensity( - self.hass, em, self.config_entry.data - ) - except InvalidToken as err: - raise ConfigEntryError from err - except ElectricityMapsError as err: - raise UpdateFailed(str(err)) from err + try: + return await fetch_latest_carbon_intensity( + self.hass, self.client, self.config_entry.data + ) + except InvalidToken as err: + raise ConfigEntryError from err + except ElectricityMapsError as err: + raise UpdateFailed(str(err)) from err diff --git a/tests/components/co2signal/__init__.py b/tests/components/co2signal/__init__.py index 1f3d6a83c05..65764d75fe4 100644 --- a/tests/components/co2signal/__init__.py +++ b/tests/components/co2signal/__init__.py @@ -1,11 +1,18 @@ """Tests for the CO2 Signal integration.""" +from aioelectricitymaps.models import ( + CarbonIntensityData, + CarbonIntensityResponse, + CarbonIntensityUnit, +) -VALID_PAYLOAD = { - "status": "ok", - "countryCode": "FR", - "data": { - "carbonIntensity": 45.98623190095805, - "fossilFuelPercentage": 5.461182741937103, - }, - "units": {"carbonIntensity": "gCO2eq/kWh"}, -} +VALID_RESPONSE = CarbonIntensityResponse( + status="ok", + country_code="FR", + data=CarbonIntensityData( + carbon_intensity=45.98623190095805, + fossil_fuel_percentage=5.461182741937103, + ), + units=CarbonIntensityUnit( + carbon_intensity="gCO2eq/kWh", + ), +) diff --git a/tests/components/co2signal/conftest.py b/tests/components/co2signal/conftest.py new file mode 100644 index 00000000000..8eb0116bc88 --- /dev/null +++ b/tests/components/co2signal/conftest.py @@ -0,0 +1,52 @@ +"""Fixtures for Electricity maps integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.co2signal import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.co2signal import VALID_RESPONSE + + +@pytest.fixture(name="electricity_maps") +def mock_electricity_maps() -> Generator[None, MagicMock, None]: + """Mock the ElectricityMaps client.""" + + with patch( + "homeassistant.components.co2signal.ElectricityMaps", + autospec=True, + ) as electricity_maps, patch( + "homeassistant.components.co2signal.config_flow.ElectricityMaps", + new=electricity_maps, + ): + client = electricity_maps.return_value + client.latest_carbon_intensity_by_coordinates.return_value = VALID_RESPONSE + client.latest_carbon_intensity_by_country_code.return_value = VALID_RESPONSE + + yield client + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "api_key", "location": ""}, + entry_id="904a74160aa6f335526706bee85dfb83", + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, electricity_maps: AsyncMock +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 7d782e6e3bd..b717e159986 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,5 +1,5 @@ """Test the CO2 Signal config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aioelectricitymaps.exceptions import ( ElectricityMapsDecodeError, @@ -13,9 +13,8 @@ from homeassistant.components.co2signal import DOMAIN, config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import VALID_PAYLOAD - +@pytest.mark.usefixtures("electricity_maps") async def test_form_home(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -26,9 +25,6 @@ async def test_form_home(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", - return_value=VALID_PAYLOAD, - ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -49,6 +45,7 @@ async def test_form_home(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("electricity_maps") async def test_form_coordinates(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -68,9 +65,6 @@ async def test_form_coordinates(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM with patch( - "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", - return_value=VALID_PAYLOAD, - ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -93,6 +87,7 @@ async def test_form_coordinates(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("electricity_maps") async def test_form_country(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -112,9 +107,6 @@ async def test_form_country(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM with patch( - "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", - return_value=VALID_PAYLOAD, - ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -151,39 +143,43 @@ async def test_form_country(hass: HomeAssistant) -> None: "json decode error", ], ) -async def test_form_error_handling(hass: HomeAssistant, side_effect, err_code) -> None: +async def test_form_error_handling( + hass: HomeAssistant, + electricity_maps: AsyncMock, + side_effect: Exception, + err_code: str, +) -> None: """Test we handle expected errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", - side_effect=side_effect, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "location": config_flow.TYPE_USE_HOME, - "api_key": "api_key", - }, - ) + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = side_effect + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": err_code} - with patch( - "homeassistant.components.co2signal.config_flow.ElectricityMaps._get", - return_value=VALID_PAYLOAD, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "location": config_flow.TYPE_USE_HOME, - "api_key": "api_key", - }, - ) - await hass.async_block_till_done() + # reset mock and test if now succeeds + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = None + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "CO2 Signal" diff --git a/tests/components/co2signal/test_diagnostics.py b/tests/components/co2signal/test_diagnostics.py index 15f0027dbd4..edc0007952b 100644 --- a/tests/components/co2signal/test_diagnostics.py +++ b/tests/components/co2signal/test_diagnostics.py @@ -1,38 +1,23 @@ """Test the CO2Signal diagnostics.""" -from unittest.mock import patch +import pytest from syrupy import SnapshotAssertion -from homeassistant.components.co2signal import DOMAIN -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from . import VALID_PAYLOAD from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("setup_integration") async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_API_KEY: "api_key", "location": ""}, - entry_id="904a74160aa6f335526706bee85dfb83", - ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.co2signal.coordinator.ElectricityMaps._get", - return_value=VALID_PAYLOAD, - ): - assert await async_setup_component(hass, DOMAIN, {}) - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert result == snapshot From 2515dbeee1235be5c32f1df434777495f8e1cd3a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 24 Nov 2023 19:55:00 +0100 Subject: [PATCH 722/982] Bump aioshelly to version 6.1.0 (#104456) * Bump aioshelly * Use MODEL_* consts from aioshelly * Add missing models to BATTERY_DEVICES_WITH_PERMANENT_CONNECTION --- homeassistant/components/shelly/const.py | 60 ++++++++++++------- .../components/shelly/coordinator.py | 3 +- homeassistant/components/shelly/event.py | 3 +- homeassistant/components/shelly/light.py | 7 ++- homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/switch.py | 5 +- homeassistant/components/shelly/utils.py | 21 +++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/__init__.py | 3 +- tests/components/shelly/conftest.py | 7 ++- tests/components/shelly/test_binary_sensor.py | 5 +- tests/components/shelly/test_climate.py | 7 ++- tests/components/shelly/test_config_flow.py | 59 +++++++++--------- tests/components/shelly/test_coordinator.py | 5 +- .../components/shelly/test_device_trigger.py | 3 +- tests/components/shelly/test_diagnostics.py | 5 +- tests/components/shelly/test_event.py | 3 +- tests/components/shelly/test_light.py | 27 ++++++--- tests/components/shelly/test_switch.py | 3 +- tests/components/shelly/test_utils.py | 40 ++++++++----- 21 files changed, 166 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index db7623f684e..a90aba8db62 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -6,6 +6,22 @@ from logging import Logger, getLogger import re from typing import Final +from aioshelly.const import ( + MODEL_BULB, + MODEL_BULB_RGBW, + MODEL_BUTTON1, + MODEL_BUTTON1_V2, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_DUO, + MODEL_GAS, + MODEL_MOTION, + MODEL_MOTION_2, + MODEL_RGBW2, + MODEL_VALVE, + MODEL_VINTAGE_V2, + MODEL_WALL_DISPLAY, +) from awesomeversion import AwesomeVersion DOMAIN: Final = "shelly" @@ -24,29 +40,29 @@ LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 MAX_TRANSITION_TIME: Final = 5000 RGBW_MODELS: Final = ( - "SHBLB-1", - "SHRGBW2", + MODEL_BULB, + MODEL_RGBW2, ) MODELS_SUPPORTING_LIGHT_TRANSITION: Final = ( - "SHBDUO-1", - "SHCB-1", - "SHDM-1", - "SHDM-2", - "SHRGBW2", - "SHVIN-1", + MODEL_DUO, + MODEL_BULB_RGBW, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_RGBW2, + MODEL_VINTAGE_V2, ) MODELS_SUPPORTING_LIGHT_EFFECTS: Final = ( - "SHBLB-1", - "SHCB-1", - "SHRGBW2", + MODEL_BULB, + MODEL_BULB_RGBW, + MODEL_RGBW2, ) # Bulbs that support white & color modes DUAL_MODE_LIGHT_MODELS: Final = ( - "SHBLB-1", - "SHCB-1", + MODEL_BULB, + MODEL_BULB_RGBW, ) # Refresh interval for REST sensors @@ -79,7 +95,11 @@ INPUTS_EVENTS_DICT: Final = { } # List of battery devices that maintain a permanent WiFi connection -BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = ["SHMOS-01"] +BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = [ + MODEL_MOTION, + MODEL_MOTION_2, + MODEL_VALVE, +] # Button/Click events for Block & RPC devices EVENT_SHELLY_CLICK: Final = "shelly.click" @@ -124,7 +144,7 @@ INPUTS_EVENTS_SUBTYPES: Final = { "button4": 4, } -SHBTN_MODELS: Final = ["SHBTN-1", "SHBTN-2"] +SHBTN_MODELS: Final = [MODEL_BUTTON1, MODEL_BUTTON1_V2] STANDARD_RGB_EFFECTS: Final = { 0: "Off", @@ -165,7 +185,7 @@ UPTIME_DEVIATION: Final = 5 # Time to wait before reloading entry upon device config change ENTRY_RELOAD_COOLDOWN = 60 -SHELLY_GAS_MODELS = ["SHGS-1"] +SHELLY_GAS_MODELS = [MODEL_GAS] BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2") @@ -192,13 +212,11 @@ OTA_ERROR = "ota_error" OTA_PROGRESS = "ota_progress" OTA_SUCCESS = "ota_success" -MODEL_WALL_DISPLAY = "SAWD-0A1XX10EU1" - GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog" GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( MODEL_WALL_DISPLAY, - "SHMOS-01", - "SHMOS-02", - "SHTRV-01", + MODEL_MOTION, + MODEL_MOTION_2, + MODEL_VALVE, ) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index e648a80420a..b618656313d 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -10,6 +10,7 @@ from typing import Any, Generic, TypeVar, cast import aioshelly from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType +from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.rpc_device import RpcDevice, RpcUpdateType from awesomeversion import AwesomeVersion @@ -219,7 +220,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): # Shelly TRV sends information about changing the configuration for no # reason, reloading the config entry is not needed for it. - if self.model == "SHTRV-01": + if self.model == MODEL_VALVE: self._last_cfg_changed = None # For dual mode bulbs ignore change if it is due to mode/effect change diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 1b5cf911e85..af323c82a24 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from aioshelly.block_device import Block +from aioshelly.const import MODEL_I3 from homeassistant.components.event import ( DOMAIN as EVENT_DOMAIN, @@ -135,7 +136,7 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity): self.channel = channel = int(block.channel or 0) + 1 self._attr_unique_id = f"{super().unique_id}-{channel}" - if coordinator.model == "SHIX3-1": + if coordinator.model == MODEL_I3: self._attr_event_types = list(SHIX3_1_INPUTS_EVENTS_TYPES) else: self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 1c3a85f2f5e..829a60b3a9e 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import MODEL_BULB from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -254,7 +255,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): @property def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" - if self.coordinator.model == "SHBLB-1": + if self.coordinator.model == MODEL_BULB: return list(SHBLB_1_RGB_EFFECTS.values()) return list(STANDARD_RGB_EFFECTS.values()) @@ -267,7 +268,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): else: effect_index = self.block.effect - if self.coordinator.model == "SHBLB-1": + if self.coordinator.model == MODEL_BULB: return SHBLB_1_RGB_EFFECTS[effect_index] return STANDARD_RGB_EFFECTS[effect_index] @@ -326,7 +327,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): if ATTR_EFFECT in kwargs and ATTR_COLOR_TEMP_KELVIN not in kwargs: # Color effect change - used only in color mode, switch device mode to color set_mode = "color" - if self.coordinator.model == "SHBLB-1": + if self.coordinator.model == MODEL_BULB: effect_dict = SHBLB_1_RGB_EFFECTS else: effect_dict = STANDARD_RGB_EFFECTS diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c76e2102fa1..b8185712d31 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==6.0.0"], + "requirements": ["aioshelly==6.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 5610956e790..35429c858f5 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -65,7 +66,7 @@ def async_setup_block_entry( assert coordinator # Add Shelly Gas Valve as a switch - if coordinator.model == "SHGS-1": + if coordinator.model == MODEL_GAS: async_setup_block_attribute_entities( hass, async_add_entities, @@ -77,7 +78,7 @@ def async_setup_block_entry( # In roller mode the relay blocks exist but do not contain required info if ( - coordinator.model in ["SHSW-21", "SHSW-25"] + coordinator.model in [MODEL_2, MODEL_25] and coordinator.device.settings["mode"] != "relay" ): return diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index eff21e71413..6b5c59f28db 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -6,7 +6,14 @@ from typing import Any, cast from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice -from aioshelly.const import MODEL_NAMES +from aioshelly.const import ( + MODEL_1L, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_EM3, + MODEL_I3, + MODEL_NAMES, +) from aioshelly.rpc_device import RpcDevice, WsServer from homeassistant.components.http import HomeAssistantView @@ -57,7 +64,11 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: if block.type == "input": # Shelly Dimmer/1L has two input channels and missing "num_inputs" - if device.settings["device"]["type"] in ["SHDM-1", "SHDM-2", "SHSW-L"]: + if device.settings["device"]["type"] in [ + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_1L, + ]: channels = 2 else: channels = device.shelly.get("num_inputs") @@ -106,7 +117,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: if channel_name: return channel_name - if device.settings["device"]["type"] == "SHEM-3": + if device.settings["device"]["type"] == MODEL_EM3: base = ord("A") else: base = ord("1") @@ -136,7 +147,7 @@ def is_block_momentary_input( return False # Shelly 1L has two button settings in the first channel - if settings["device"]["type"] == "SHSW-L": + if settings["device"]["type"] == MODEL_1L: channel = int(block.channel or 0) + 1 button_type = button[0].get("btn" + str(channel) + "_type") else: @@ -180,7 +191,7 @@ def get_block_input_triggers( if device.settings["device"]["type"] in SHBTN_MODELS: trigger_types = SHBTN_INPUTS_EVENTS_TYPES - elif device.settings["device"]["type"] == "SHIX3-1": + elif device.settings["device"]["type"] == MODEL_I3: trigger_types = SHIX3_1_INPUTS_EVENTS_TYPES else: trigger_types = BASIC_INPUTS_EVENTS_TYPES diff --git a/requirements_all.txt b/requirements_all.txt index d35ee88e994..5787e1a8554 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.0.0 +aioshelly==6.1.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ad933fa42c..3e9c1a19c2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -326,7 +326,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.0.0 +aioshelly==6.1.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 464118ac99b..0384e9255a3 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock +from aioshelly.const import MODEL_25 from freezegun.api import FrozenDateTimeFactory import pytest @@ -30,7 +31,7 @@ MOCK_MAC = "123456789ABC" async def init_integration( hass: HomeAssistant, gen: int, - model="SHSW-25", + model=MODEL_25, sleep_period=0, options: dict[str, Any] | None = None, skip_setup: bool = False, diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 12d84200720..aeeaf9242a1 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, PropertyMock, patch from aioshelly.block_device import BlockDevice, BlockUpdateType +from aioshelly.const import MODEL_1, MODEL_25, MODEL_PLUS_2PM from aioshelly.rpc_device import RpcDevice, RpcUpdateType import pytest @@ -22,7 +23,7 @@ MOCK_SETTINGS = { "device": { "mac": MOCK_MAC, "hostname": "test-host", - "type": "SHSW-25", + "type": MODEL_25, "num_outputs": 2, }, "coiot": {"update_period": 15}, @@ -167,7 +168,7 @@ MOCK_SHELLY_RPC = { "name": "Test Gen2", "id": "shellyplus2pm-123456789abc", "mac": MOCK_MAC, - "model": "SNSW-002P16EU", + "model": MODEL_PLUS_2PM, "gen": 2, "fw_id": "20220830-130540/0.11.0-gfa1bc37", "ver": "0.11.0", @@ -289,7 +290,7 @@ async def mock_block_device(): status=MOCK_STATUS_COAP, firmware_version="some fw string", initialized=True, - model="SHSW-1", + model=MODEL_1, gen=1, ) type(device).name = PropertyMock(return_value="Test name") diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 8905ff5c3e8..8a5e0108ad7 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -1,4 +1,5 @@ """Tests for Shelly binary sensor platform.""" +from aioshelly.const import MODEL_MOTION from freezegun.api import FrozenDateTimeFactory from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -77,9 +78,9 @@ async def test_block_rest_binary_sensor_connected_battery_devices( """Test block REST binary sensor for connected battery devices.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHMOS-01") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_MOTION) monkeypatch.setitem(mock_block_device.settings["coiot"], "update_period", 3600) - await init_integration(hass, 1, model="SHMOS-01") + await init_integration(hass, 1, model=MODEL_MOTION) assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index d1e37f77574..fe518b8509c 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -2,6 +2,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, PropertyMock +from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest @@ -54,7 +55,7 @@ async def test_climate_hvac_mode( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") - await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") + await init_integration(hass, 1, sleep_period=1000, model=MODEL_VALVE) # Make device online mock_block_device.mock_update() @@ -155,7 +156,7 @@ async def test_climate_set_preset_mode( monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", None) - await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") + await init_integration(hass, 1, sleep_period=1000, model=MODEL_VALVE) # Make device online mock_block_device.mock_update() @@ -507,7 +508,7 @@ async def test_device_not_calibrated( """Test to create an issue when the device is not calibrated.""" issue_registry: ir.IssueRegistry = ir.async_get(hass) - await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") + await init_integration(hass, 1, sleep_period=1000, model=MODEL_VALVE) # Make device online mock_block_device.mock_update() diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 073847e0308..9482080a1a3 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -5,6 +5,7 @@ from dataclasses import replace from ipaddress import ip_address from unittest.mock import AsyncMock, patch +from aioshelly.const import MODEL_1, MODEL_PLUS_2PM from aioshelly.exceptions import ( DeviceConnectionError, FirmwareUnsupported, @@ -52,8 +53,8 @@ DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( @pytest.mark.parametrize( ("gen", "model"), [ - (1, "SHSW-1"), - (2, "SNSW-002P16EU"), + (1, MODEL_1), + (2, MODEL_PLUS_2PM), ], ) async def test_form( @@ -68,7 +69,7 @@ async def test_form( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False, "gen": gen}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": gen}, ), patch( "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup, patch( @@ -98,13 +99,13 @@ async def test_form( [ ( 1, - "SHSW-1", + MODEL_1, {"username": "test user", "password": "test1 password"}, "test user", ), ( 2, - "SNSW-002P16EU", + MODEL_PLUS_2PM, {"password": "test2 password"}, "admin", ), @@ -128,7 +129,7 @@ async def test_form_auth( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True, "gen": gen}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -306,7 +307,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -339,7 +340,7 @@ async def test_user_setup_ignored_device( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ), patch( "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup, patch( @@ -456,13 +457,13 @@ async def test_form_auth_errors_test_connection_gen2( [ ( 1, - "SHSW-1", - {"mac": "test-mac", "type": "SHSW-1", "auth": False, "gen": 1}, + MODEL_1, + {"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": 1}, ), ( 2, - "SNSW-002P16EU", - {"mac": "test-mac", "model": "SHSW-1", "auth": False, "gen": 2}, + MODEL_PLUS_2PM, + {"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 2}, ), ], ) @@ -525,7 +526,7 @@ async def test_zeroconf_sleeping_device( "homeassistant.components.shelly.config_flow.get_info", return_value={ "mac": "test-mac", - "type": "SHSW-1", + "type": MODEL_1, "auth": False, "sleep_mode": True, }, @@ -559,7 +560,7 @@ async def test_zeroconf_sleeping_device( assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", - "model": "SHSW-1", + "model": MODEL_1, "sleep_period": 600, "gen": 1, } @@ -573,7 +574,7 @@ async def test_zeroconf_sleeping_device_error(hass: HomeAssistant) -> None: "homeassistant.components.shelly.config_flow.get_info", return_value={ "mac": "test-mac", - "type": "SHSW-1", + "type": MODEL_1, "auth": False, "sleep_mode": True, }, @@ -600,7 +601,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -627,7 +628,7 @@ async def test_zeroconf_ignored(hass: HomeAssistant) -> None: with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -648,7 +649,7 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -700,7 +701,7 @@ async def test_zeroconf_require_auth(hass: HomeAssistant, mock_block_device) -> with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": True}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -726,7 +727,7 @@ async def test_zeroconf_require_auth(hass: HomeAssistant, mock_block_device) -> assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", - "model": "SHSW-1", + "model": MODEL_1, "sleep_period": 0, "gen": 1, "username": "test username", @@ -754,7 +755,7 @@ async def test_reauth_successful( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True, "gen": gen}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -790,7 +791,7 @@ async def test_reauth_unsuccessful(hass: HomeAssistant, gen, user_input) -> None with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True, "gen": gen}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ), patch( "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=InvalidAuthError), @@ -1029,7 +1030,7 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": "SHSW-1"}, + data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1038,7 +1039,7 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "", "type": "SHSW-1", "auth": False}, + return_value={"mac": "", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -1061,7 +1062,7 @@ async def test_zeroconf_already_configured_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": "SHSW-1"}, + data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1070,7 +1071,7 @@ async def test_zeroconf_already_configured_triggers_refresh( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "AABBCCDDEEFF", "type": "SHSW-1", "auth": False}, + return_value={"mac": "AABBCCDDEEFF", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -1093,7 +1094,7 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": "SHSW-1"}, + data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1105,7 +1106,7 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "AABBCCDDEEFF", "type": "SHSW-1", "auth": False}, + return_value={"mac": "AABBCCDDEEFF", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -1148,7 +1149,7 @@ async def test_sleeping_device_gen2_with_new_firmware( assert result["data"] == { "host": "1.1.1.1", - "model": "SNSW-002P16EU", + "model": MODEL_PLUS_2PM, "sleep_period": 666, "gen": 2, } diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8ce80b70032..e73168c6b20 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch +from aioshelly.const import MODEL_BULB, MODEL_BUTTON1 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from freezegun.api import FrozenDateTimeFactory @@ -79,7 +80,7 @@ async def test_block_no_reload_on_bulb_changes( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block no reload on bulb mode/effect change.""" - await init_integration(hass, 1, model="SHBLB-1") + await init_integration(hass, 1, model=MODEL_BULB) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 1) mock_block_device.mock_update() @@ -284,7 +285,7 @@ async def test_block_button_click_event( "sensor_ids", {"inputEvent": "S", "inputEventCnt": 0}, ) - entry = await init_integration(hass, 1, model="SHBTN-1", sleep_period=1000) + entry = await init_integration(hass, 1, model=MODEL_BUTTON1, sleep_period=1000) # Make device online mock_block_device.mock_update() diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 143501ef620..9a63e66980a 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -1,4 +1,5 @@ """The tests for Shelly device triggers.""" +from aioshelly.const import MODEL_BUTTON1 import pytest from pytest_unordered import unordered @@ -108,7 +109,7 @@ async def test_get_triggers_rpc_device(hass: HomeAssistant, mock_rpc_device) -> async def test_get_triggers_button(hass: HomeAssistant, mock_block_device) -> None: """Test we get the expected triggers from a shelly button.""" - entry = await init_integration(hass, 1, model="SHBTN-1") + entry = await init_integration(hass, 1, model=MODEL_BUTTON1) dev_reg = async_get_dev_reg(hass) device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 39f1ef8d723..13126db0a0e 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import ANY from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT +from aioshelly.const import MODEL_25 from homeassistant.components.diagnostics import REDACTED from homeassistant.components.shelly.const import ( @@ -40,7 +41,7 @@ async def test_block_config_entry_diagnostics( "bluetooth": "not initialized", "device_info": { "name": "Test name", - "model": "SHSW-25", + "model": MODEL_25, "sw_version": "some fw string", }, "device_settings": {"coiot": {"update_period": 15}}, @@ -136,7 +137,7 @@ async def test_rpc_config_entry_diagnostics( }, "device_info": { "name": "Test name", - "model": "SHSW-25", + "model": MODEL_25, "sw_version": "some fw string", }, "device_settings": {}, diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index b7824d8d7ac..09439adc6f7 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -1,6 +1,7 @@ """Tests for Shelly button platform.""" from __future__ import annotations +from aioshelly.const import MODEL_I3 from pytest_unordered import unordered from homeassistant.components.event import ( @@ -104,7 +105,7 @@ async def test_block_event(hass: HomeAssistant, monkeypatch, mock_block_device) async def test_block_event_shix3_1(hass: HomeAssistant, mock_block_device) -> None: """Test block device event for SHIX3-1.""" - await init_integration(hass, 1, model="SHIX3-1") + await init_integration(hass, 1, model=MODEL_I3) entity_id = "event.test_name_channel_1" state = hass.states.get(entity_id) diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 69d0fccf421..e3aea966230 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -1,4 +1,13 @@ """Tests for Shelly light platform.""" +from aioshelly.const import ( + MODEL_BULB, + MODEL_BULB_RGBW, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_DUO, + MODEL_RGBW2, + MODEL_VINTAGE_V2, +) import pytest from homeassistant.components.light import ( @@ -33,7 +42,7 @@ LIGHT_BLOCK_ID = 2 async def test_block_device_rgbw_bulb(hass: HomeAssistant, mock_block_device) -> None: """Test block device RGBW bulb.""" - await init_integration(hass, 1, model="SHBLB-1") + await init_integration(hass, 1, model=MODEL_BULB) # Test initial state = hass.states.get("light.test_name_channel_1") @@ -113,7 +122,7 @@ async def test_block_device_rgb_bulb( ) -> None: """Test block device RGB bulb.""" monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") - await init_integration(hass, 1, model="SHCB-1") + await init_integration(hass, 1, model=MODEL_BULB_RGBW) # Test initial state = hass.states.get("light.test_name_channel_1") @@ -215,7 +224,7 @@ async def test_block_device_white_bulb( monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "colorTemp") monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "effect") - await init_integration(hass, 1, model="SHVIN-1") + await init_integration(hass, 1, model=MODEL_VINTAGE_V2) # Test initial state = hass.states.get("light.test_name_channel_1") @@ -259,12 +268,12 @@ async def test_block_device_white_bulb( @pytest.mark.parametrize( "model", [ - "SHBDUO-1", - "SHCB-1", - "SHDM-1", - "SHDM-2", - "SHRGBW2", - "SHVIN-1", + MODEL_DUO, + MODEL_BULB_RGBW, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_RGBW2, + MODEL_VINTAGE_V2, ], ) async def test_block_device_support_transition( diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 9bc065ed166..69e1423f75a 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -2,6 +2,7 @@ from copy import deepcopy from unittest.mock import AsyncMock +from aioshelly.const import MODEL_GAS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest @@ -238,7 +239,7 @@ async def test_block_device_gas_valve( ) -> None: """Test block device Shelly Gas with Valve addon.""" registry = er.async_get(hass) - await init_integration(hass, 1, "SHGS-1") + await init_integration(hass, 1, MODEL_GAS) entity_id = "switch.test_name_valve" entry = registry.async_get(entity_id) diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 07ba0d724c2..e47f9e451b4 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -1,11 +1,19 @@ """Tests for Shelly utils.""" -import pytest - -from homeassistant.components.shelly.const import ( - GEN1_RELEASE_URL, - GEN2_RELEASE_URL, +from aioshelly.const import ( + MODEL_1, + MODEL_1L, + MODEL_BUTTON1, + MODEL_BUTTON1_V2, + MODEL_DIMMER_2, + MODEL_EM3, + MODEL_I3, + MODEL_MOTION, + MODEL_PLUS_2PM_V2, MODEL_WALL_DISPLAY, ) +import pytest + +from homeassistant.components.shelly.const import GEN1_RELEASE_URL, GEN2_RELEASE_URL from homeassistant.components.shelly.utils import ( get_block_channel_name, get_block_device_sleep_period, @@ -45,7 +53,7 @@ async def test_block_get_number_of_channels(mock_block_device, monkeypatch) -> N == 4 ) - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHDM-2") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_DIMMER_2) assert ( get_number_of_channels( mock_block_device, @@ -67,7 +75,7 @@ async def test_block_get_block_channel_name(mock_block_device, monkeypatch) -> N == "Test name channel 1" ) - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHEM-3") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_EM3) assert ( get_block_channel_name( @@ -113,7 +121,7 @@ async def test_is_block_momentary_input(mock_block_device, monkeypatch) -> None: ) monkeypatch.setitem(mock_block_device.settings, "mode", "relay") - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHSW-L") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_1L) assert ( is_block_momentary_input( mock_block_device.settings, mock_block_device.blocks[DEVICE_BLOCK_ID], True @@ -131,7 +139,7 @@ async def test_is_block_momentary_input(mock_block_device, monkeypatch) -> None: is False ) - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHBTN-2") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_BUTTON1_V2) assert ( is_block_momentary_input( @@ -183,7 +191,7 @@ async def test_get_block_input_triggers(mock_block_device, monkeypatch) -> None: ) ) == {("long", "button"), ("single", "button")} - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHBTN-1") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_BUTTON1) assert set( get_block_input_triggers( mock_block_device, mock_block_device.blocks[DEVICE_BLOCK_ID] @@ -195,7 +203,7 @@ async def test_get_block_input_triggers(mock_block_device, monkeypatch) -> None: ("triple", "button"), } - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHIX3-1") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_I3) assert set( get_block_input_triggers( mock_block_device, mock_block_device.blocks[DEVICE_BLOCK_ID] @@ -235,12 +243,12 @@ async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: @pytest.mark.parametrize( ("gen", "model", "beta", "expected"), [ - (1, "SHMOS-01", False, None), - (1, "SHSW-1", False, GEN1_RELEASE_URL), - (1, "SHSW-1", True, None), + (1, MODEL_MOTION, False, None), + (1, MODEL_1, False, GEN1_RELEASE_URL), + (1, MODEL_1, True, None), (2, MODEL_WALL_DISPLAY, False, None), - (2, "SNSW-102P16EU", False, GEN2_RELEASE_URL), - (2, "SNSW-102P16EU", True, None), + (2, MODEL_PLUS_2PM_V2, False, GEN2_RELEASE_URL), + (2, MODEL_PLUS_2PM_V2, True, None), ], ) def test_get_release_url( From e161bb9e414e0fa342a6c82cea75676594fe988d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 24 Nov 2023 19:56:15 +0100 Subject: [PATCH 723/982] fix BLE stop error for disconnected Shelly devices (#104457) --- homeassistant/components/shelly/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index b618656313d..d1f9d6943bf 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -584,7 +584,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ble_scanner_mode = self.entry.options.get( CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED ) - if ble_scanner_mode == BLEScannerMode.DISABLED: + if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) return if AwesomeVersion(self.device.version) < BLE_MIN_VERSION: From 4700ad7817af0997ef052ba4693758c58e1e5585 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 24 Nov 2023 20:07:17 +0100 Subject: [PATCH 724/982] Add HVACMode.OFF to Plugwise Adam (#103360) --- homeassistant/components/plugwise/climate.py | 79 +++++++--- .../components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../plugwise/fixtures/adam_jip/all_data.json | 2 +- .../fixtures/m_adam_cooling/all_data.json | 16 +- tests/components/plugwise/test_climate.py | 147 +++++++++++++++--- tests/components/plugwise/test_select.py | 26 +++- 8 files changed, 222 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 42004ce7088..efad1b7466b 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -46,6 +46,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _previous_mode: str = "heating" + def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, @@ -55,10 +57,15 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): super().__init__(coordinator, device_id) self._attr_extra_state_attributes = {} self._attr_unique_id = f"{device_id}-climate" - + self.cdr_gateway = coordinator.data.gateway + gateway_id: str = coordinator.data.gateway["gateway_id"] + self.gateway_data = coordinator.data.devices[gateway_id] # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if self.coordinator.data.gateway["cooling_present"]: + if ( + self.cdr_gateway["cooling_present"] + and self.cdr_gateway["smile_name"] != "Adam" + ): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -73,6 +80,20 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self.device["thermostat"]["resolution"], 0.1 ) + def _previous_action_mode(self, coordinator: PlugwiseDataUpdateCoordinator) -> None: + """Return the previous action-mode when the regulation-mode is not heating or cooling. + + Helper for set_hvac_mode(). + """ + # When no cooling available, _previous_mode is always heating + if ( + "regulation_modes" in self.gateway_data + and "cooling" in self.gateway_data["regulation_modes"] + ): + mode = self.gateway_data["select_regulation_mode"] + if mode in ("cooling", "heating"): + self._previous_mode = mode + @property def current_temperature(self) -> float: """Return the current temperature.""" @@ -105,33 +126,46 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: - """Return HVAC operation ie. auto, heat, or heat_cool mode.""" + """Return HVAC operation ie. auto, cool, heat, heat_cool, or off mode.""" if (mode := self.device.get("mode")) is None or mode not in self.hvac_modes: return HVACMode.HEAT return HVACMode(mode) @property def hvac_modes(self) -> list[HVACMode]: - """Return the list of available HVACModes.""" - hvac_modes = [HVACMode.HEAT] - if self.coordinator.data.gateway["cooling_present"]: - hvac_modes = [HVACMode.HEAT_COOL] + """Return a list of available HVACModes.""" + hvac_modes: list[HVACMode] = [] + if "regulation_modes" in self.gateway_data: + hvac_modes.append(HVACMode.OFF) if self.device["available_schedules"] != ["None"]: hvac_modes.append(HVACMode.AUTO) + if self.cdr_gateway["cooling_present"]: + if "regulation_modes" in self.gateway_data: + if self.gateway_data["select_regulation_mode"] == "cooling": + hvac_modes.append(HVACMode.COOL) + if self.gateway_data["select_regulation_mode"] == "heating": + hvac_modes.append(HVACMode.HEAT) + else: + hvac_modes.append(HVACMode.HEAT_COOL) + else: + hvac_modes.append(HVACMode.HEAT) + return hvac_modes @property - def hvac_action(self) -> HVACAction | None: + def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported.""" - heater: str | None = self.coordinator.data.gateway["heater_id"] - if heater: - heater_data = self.coordinator.data.devices[heater] - if heater_data["binary_sensors"]["heating_state"]: - return HVACAction.HEATING - if heater_data["binary_sensors"].get("cooling_state"): - return HVACAction.COOLING + # Keep track of the previous action-mode + self._previous_action_mode(self.coordinator) + + heater: str = self.coordinator.data.gateway["heater_id"] + heater_data = self.coordinator.data.devices[heater] + if heater_data["binary_sensors"]["heating_state"]: + return HVACAction.HEATING + if heater_data["binary_sensors"].get("cooling_state", False): + return HVACAction.COOLING return HVACAction.IDLE @@ -168,9 +202,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if hvac_mode not in self.hvac_modes: raise HomeAssistantError("Unsupported hvac_mode") - await self.coordinator.api.set_schedule_state( - self.device["location"], "on" if hvac_mode == HVACMode.AUTO else "off" - ) + if hvac_mode == self.hvac_mode: + return + + if hvac_mode == HVACMode.OFF: + await self.coordinator.api.set_regulation_mode(hvac_mode) + else: + await self.coordinator.api.set_schedule_state( + self.device["location"], + "on" if hvac_mode == HVACMode.AUTO else "off", + ) + if self.hvac_mode == HVACMode.OFF: + await self.coordinator.api.set_regulation_mode(self._previous_mode) @plugwise_command async def async_set_preset_mode(self, preset_mode: str) -> None: diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 1155aaffdf8..74b196b6edd 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.33.2"], + "requirements": ["plugwise==0.34.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 5787e1a8554..bdb0fcf2388 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1477,7 +1477,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.2 +plugwise==0.34.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e9c1a19c2f..3037c994658 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1135,7 +1135,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.2 +plugwise==0.34.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index bc1bc9c8c0c..dacee20c644 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -8,7 +8,7 @@ "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "06aecb3d00354375924f50c47af36bd2", - "mode": "heat", + "mode": "off", "model": "Lisa", "name": "Slaapkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 126852e945d..624547155a3 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -55,22 +55,20 @@ "available_schedules": ["Weekschema", "Badkamer", "Test"], "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", - "mode": "heat_cool", + "mode": "cool", "model": "ThermoTouch", "name": "Anna", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "select_schedule": "Weekschema", "selected_schedule": "None", "sensors": { - "setpoint_high": 23.5, - "setpoint_low": 4.0, + "setpoint": 23.5, "temperature": 25.8 }, "thermostat": { "lower_bound": 1.0, "resolution": 0.01, - "setpoint_high": 23.5, - "setpoint_low": 4.0, + "setpoint": 23.5, "upper_bound": 35.0 }, "vendor": "Plugwise" @@ -115,9 +113,8 @@ "select_schedule": "Badkamer", "sensors": { "battery": 56, - "setpoint_high": 23.5, - "setpoint_low": 20.0, - "temperature": 239 + "setpoint": 23.5, + "temperature": 23.9 }, "temperature_offset": { "lower_bound": -2.0, @@ -128,8 +125,7 @@ "thermostat": { "lower_bound": 0.0, "resolution": 0.01, - "setpoint_high": 25.0, - "setpoint_low": 19.0, + "setpoint": 25.0, "upper_bound": 99.9 }, "vendor": "Plugwise", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 2d9885637df..8b4c4d5a745 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -13,6 +13,10 @@ from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed +HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( + "homeassistant.components.plugwise.coordinator.Smile.async_update" +) + async def test_adam_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry @@ -21,7 +25,7 @@ async def test_adam_climate_entity_attributes( state = hass.states.get("climate.zone_lisa_wk") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_modes"] == [HVACMode.HEAT, HVACMode.AUTO] + assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] # hvac_action is not asserted as the fixture is not in line with recent firmware functionality assert "preset_modes" in state.attributes @@ -39,7 +43,7 @@ async def test_adam_climate_entity_attributes( state = hass.states.get("climate.zone_thermostat_jessie") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_modes"] == [HVACMode.HEAT, HVACMode.AUTO] + assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] # hvac_action is not asserted as the fixture is not in line with recent firmware functionality assert "preset_modes" in state.attributes @@ -62,13 +66,21 @@ async def test_adam_2_climate_entity_attributes( assert state assert state.state == HVACMode.HEAT assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [HVACMode.HEAT, HVACMode.AUTO] + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] state = hass.states.get("climate.lisa_badkamer") assert state assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [HVACMode.HEAT, HVACMode.AUTO] + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] async def test_adam_3_climate_entity_attributes( @@ -78,11 +90,58 @@ async def test_adam_3_climate_entity_attributes( state = hass.states.get("climate.anna") assert state - assert state.state == HVACMode.HEAT_COOL + assert state.state == HVACMode.COOL assert state.attributes["hvac_action"] == "cooling" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT_COOL, + HVACMode.OFF, HVACMode.AUTO, + HVACMode.COOL, + ] + data = mock_smile_adam_3.async_update.return_value + data.devices["da224107914542988a88561b4452b0f6"][ + "select_regulation_mode" + ] = "heating" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "heat" + data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ + "cooling_state" + ] = False + data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ + "heating_state" + ] = True + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + state = hass.states.get("climate.anna") + assert state + assert state.state == HVACMode.HEAT + assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] + data = mock_smile_adam_3.async_update.return_value + data.devices["da224107914542988a88561b4452b0f6"][ + "select_regulation_mode" + ] = "cooling" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "cool" + data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ + "cooling_state" + ] = True + data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ + "heating_state" + ] = False + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + state = hass.states.get("climate.anna") + assert state + assert state.state == HVACMode.COOL + assert state.attributes["hvac_action"] == "cooling" + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, ] @@ -173,6 +232,60 @@ async def test_adam_climate_entity_climate_changes( ) +async def test_adam_climate_off_mode_change( + hass: HomeAssistant, + mock_smile_adam_4: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test handling of user requests in adam climate device environment.""" + state = hass.states.get("climate.slaapkamer") + assert state + assert state.state == HVACMode.OFF + await hass.services.async_call( + "climate", + "set_hvac_mode", + { + "entity_id": "climate.slaapkamer", + "hvac_mode": "heat", + }, + blocking=True, + ) + assert mock_smile_adam_4.set_schedule_state.call_count == 1 + assert mock_smile_adam_4.set_regulation_mode.call_count == 1 + mock_smile_adam_4.set_regulation_mode.assert_called_with("heating") + + state = hass.states.get("climate.kinderkamer") + assert state + assert state.state == HVACMode.HEAT + await hass.services.async_call( + "climate", + "set_hvac_mode", + { + "entity_id": "climate.kinderkamer", + "hvac_mode": "off", + }, + blocking=True, + ) + assert mock_smile_adam_4.set_schedule_state.call_count == 1 + assert mock_smile_adam_4.set_regulation_mode.call_count == 2 + mock_smile_adam_4.set_regulation_mode.assert_called_with("off") + + state = hass.states.get("climate.logeerkamer") + assert state + assert state.state == HVACMode.HEAT + await hass.services.async_call( + "climate", + "set_hvac_mode", + { + "entity_id": "climate.logeerkamer", + "hvac_mode": "heat", + }, + blocking=True, + ) + assert mock_smile_adam_4.set_schedule_state.call_count == 1 + assert mock_smile_adam_4.set_regulation_mode.call_count == 2 + + async def test_anna_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -183,10 +296,7 @@ async def test_anna_climate_entity_attributes( assert state assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.AUTO, - ] + assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] assert "no_frost" in state.attributes["preset_modes"] assert "home" in state.attributes["preset_modes"] @@ -211,8 +321,8 @@ async def test_anna_2_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "cooling" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT_COOL, HVACMode.AUTO, + HVACMode.HEAT_COOL, ] assert state.attributes["supported_features"] == 18 assert state.attributes["target_temp_high"] == 24.0 @@ -230,8 +340,8 @@ async def test_anna_3_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "idle" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT_COOL, HVACMode.AUTO, + HVACMode.HEAT_COOL, ] @@ -270,10 +380,8 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "hvac_mode": "auto"}, blocking=True, ) - assert mock_smile_anna.set_schedule_state.call_count == 1 - mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "on" - ) + # hvac_mode is already auto so not called. + assert mock_smile_anna.set_schedule_state.call_count == 0 await hass.services.async_call( "climate", @@ -281,16 +389,13 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "hvac_mode": "heat"}, blocking=True, ) - assert mock_smile_anna.set_schedule_state.call_count == 2 + assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", "off" ) data = mock_smile_anna.async_update.return_value data.devices["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = ["None"] - with patch( - "homeassistant.components.plugwise.coordinator.Smile.async_update", - return_value=data, - ): + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) await hass.async_block_till_done() state = hass.states.get("climate.anna") diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 9df20a5ffc8..f1220a07a2b 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry async def test_adam_select_entities( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test a select.""" + """Test a thermostat Select.""" state = hass.states.get("select.zone_lisa_wk_thermostat_schedule") assert state @@ -44,3 +44,27 @@ async def test_adam_change_select_entity( "on", "Badkamer Schema", ) + + +async def test_adam_select_regulation_mode( + hass: HomeAssistant, mock_smile_adam_3: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test a regulation_mode select. + + Also tests a change in climate _previous mode. + """ + + state = hass.states.get("select.adam_regulation_mode") + assert state + assert state.state == "cooling" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + "entity_id": "select.adam_regulation_mode", + "option": "heating", + }, + blocking=True, + ) + assert mock_smile_adam_3.set_regulation_mode.call_count == 1 + mock_smile_adam_3.set_regulation_mode.assert_called_with("heating") From 9962301b42dcba5705879a270c3e56c77cbb92b4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Nov 2023 21:34:09 +0100 Subject: [PATCH 725/982] Do not notify config errors during logging (#104466) --- homeassistant/bootstrap.py | 2 ++ homeassistant/components/device_tracker/legacy.py | 7 ++++++- homeassistant/components/template/config.py | 6 +++++- homeassistant/config.py | 5 ----- tests/test_bootstrap.py | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4e0a0a5dd44..0998ac6274c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -41,6 +41,7 @@ from .setup import ( DATA_SETUP, DATA_SETUP_STARTED, DATA_SETUP_TIME, + async_notify_setup_error, async_set_domains_to_be_loaded, async_setup_component, ) @@ -293,6 +294,7 @@ async def async_from_config_dict( await conf_util.async_process_ha_core_config(hass, core_config) except vol.Invalid as config_err: conf_util.async_log_schema_error(config_err, core.DOMAIN, core_config, hass) + async_notify_setup_error(hass, core.DOMAIN) return None except HomeAssistantError: _LOGGER.error( diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index b893654e8cd..f18f7984e1e 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -44,7 +44,11 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, GPSType, StateType -from homeassistant.setup import async_prepare_setup_platform, async_start_setup +from homeassistant.setup import ( + async_notify_setup_error, + async_prepare_setup_platform, + async_start_setup, +) from homeassistant.util import dt as dt_util from homeassistant.util.yaml import dump @@ -1007,6 +1011,7 @@ async def async_load_config( device["dev_id"] = cv.slugify(dev_id) except vol.Invalid as exp: async_log_schema_error(exp, dev_id, devices, hass) + async_notify_setup_error(hass, DOMAIN) else: result.append(Device(hass, **device)) return result diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index d1198b46577..9da43082d2b 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -12,8 +12,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import async_validate_trigger_config +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_notify_setup_error from . import ( binary_sensor as binary_sensor_platform, @@ -64,7 +67,7 @@ CONFIG_SECTION_SCHEMA = vol.Schema( ) -async def async_validate_config(hass, config): +async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType: """Validate config.""" if DOMAIN not in config: return config @@ -81,6 +84,7 @@ async def async_validate_config(hass, config): ) except vol.Invalid as err: async_log_schema_error(err, DOMAIN, cfg, hass) + async_notify_setup_error(hass, DOMAIN) continue legacy_warn_printed = False diff --git a/homeassistant/config.py b/homeassistant/config.py index a9c505b0a68..fc806790bc9 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -68,7 +68,6 @@ from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements -from .setup import async_notify_setup_error from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system from .util.yaml import SECRET_YAML, Secrets, load_yaml @@ -549,8 +548,6 @@ def async_log_schema_error( link: str | None = None, ) -> None: """Log a schema validation error.""" - if hass is not None: - async_notify_setup_error(hass, domain, link) message = format_schema_error(hass, ex, domain, config, link) _LOGGER.error(message) @@ -568,8 +565,6 @@ def async_log_config_validator_error( async_log_schema_error(ex, domain, config, hass, link) return - if hass is not None: - async_notify_setup_error(hass, domain, link) message = format_homeassistant_error(hass, ex, domain, config, link) _LOGGER.error(message, exc_info=ex) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index f6d3b92bb4a..b98d3d0311f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -719,7 +719,7 @@ async def test_setup_hass_invalid_core_config( event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" - with patch("homeassistant.config.async_notify_setup_error") as mock_notify: + with patch("homeassistant.bootstrap.async_notify_setup_error") as mock_notify: hass = await bootstrap.async_setup_hass( runner.RuntimeConfig( config_dir=get_test_config_dir(), From 94995f560ff53d835042932687739500c900fc1a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 24 Nov 2023 21:35:36 +0100 Subject: [PATCH 726/982] Add sensor tests to co2signal (#104464) --- .coveragerc | 1 - .../co2signal/snapshots/test_sensor.ambr | 101 ++++++++++++++++++ tests/components/co2signal/test_sensor.py | 84 +++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 tests/components/co2signal/snapshots/test_sensor.ambr create mode 100644 tests/components/co2signal/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 00116c658ca..884afdcf408 100644 --- a/.coveragerc +++ b/.coveragerc @@ -186,7 +186,6 @@ omit = homeassistant/components/control4/director_utils.py homeassistant/components/control4/light.py homeassistant/components/coolmaster/coordinator.py - homeassistant/components/co2signal/coordinator.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/crownstone/__init__.py homeassistant/components/crownstone/devices.py diff --git a/tests/components/co2signal/snapshots/test_sensor.ambr b/tests/components/co2signal/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..eb4364ed0d6 --- /dev/null +++ b/tests/components/co2signal/snapshots/test_sensor.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_sensor[sensor.electricity_maps_co2_intensity] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_maps_co2_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:molecule-co2', + 'original_name': 'CO2 intensity', + 'platform': 'co2signal', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_intensity', + 'unique_id': '904a74160aa6f335526706bee85dfb83_co2intensity', + 'unit_of_measurement': 'gCO2eq/kWh', + }) +# --- +# name: test_sensor[sensor.electricity_maps_co2_intensity].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Electricity Maps', + 'country_code': 'FR', + 'friendly_name': 'Electricity Maps CO2 intensity', + 'icon': 'mdi:molecule-co2', + 'state_class': , + 'unit_of_measurement': 'gCO2eq/kWh', + }), + 'context': , + 'entity_id': 'sensor.electricity_maps_co2_intensity', + 'last_changed': , + 'last_updated': , + 'state': '45.9862319009581', + }) +# --- +# name: test_sensor[sensor.electricity_maps_grid_fossil_fuel_percentage] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_maps_grid_fossil_fuel_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:molecule-co2', + 'original_name': 'Grid fossil fuel percentage', + 'platform': 'co2signal', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fossil_fuel_percentage', + 'unique_id': '904a74160aa6f335526706bee85dfb83_fossilFuelPercentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.electricity_maps_grid_fossil_fuel_percentage].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Electricity Maps', + 'country_code': 'FR', + 'friendly_name': 'Electricity Maps Grid fossil fuel percentage', + 'icon': 'mdi:molecule-co2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.electricity_maps_grid_fossil_fuel_percentage', + 'last_changed': , + 'last_updated': , + 'state': '5.4611827419371', + }) +# --- diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py new file mode 100644 index 00000000000..4fe3e28b991 --- /dev/null +++ b/tests/components/co2signal/test_sensor.py @@ -0,0 +1,84 @@ +"""Tests Electricity Maps sensor platform.""" +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioelectricitymaps.exceptions import ( + ElectricityMapsDecodeError, + ElectricityMapsError, + InvalidToken, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize( + "entity_name", + [ + "sensor.electricity_maps_co2_intensity", + "sensor.electricity_maps_grid_fossil_fuel_percentage", + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_name: str, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor setup and update.""" + assert (entry := entity_registry.async_get(entity_name)) + assert entry == snapshot + + assert (state := hass.states.get(entity_name)) + assert state == snapshot + + +@pytest.mark.parametrize( + "error", + [ + InvalidToken, + ElectricityMapsDecodeError, + ElectricityMapsError, + Exception, + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_sensor_update_fail( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + electricity_maps: AsyncMock, + error: Exception, +) -> None: + """Test sensor error handling.""" + assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) + assert state.state == "45.9862319009581" + assert len(electricity_maps.mock_calls) == 1 + + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = error + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = error + + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) + assert state.state == "unavailable" + assert len(electricity_maps.mock_calls) == 2 + + # reset mock and test if entity is available again + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = None + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = None + + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) + assert state.state == "45.9862319009581" + assert len(electricity_maps.mock_calls) == 3 From d4458cbac4365aaa6186806e44ef5c1033db446b Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 24 Nov 2023 21:38:46 +0100 Subject: [PATCH 727/982] Fix HomeWizard sensors unavailable when value is '0' (#104302) Co-authored-by: Franck Nijhof --- homeassistant/components/homewizard/sensor.py | 24 +- .../fixtures/HWE-P1-zero-values/data.json | 45 + .../fixtures/HWE-P1-zero-values/device.json | 7 + .../fixtures/HWE-P1-zero-values/system.json | 3 + .../homewizard/snapshots/test_sensor.ambr | 4361 +++++++++++++---- tests/components/homewizard/test_sensor.py | 39 + 6 files changed, 3634 insertions(+), 845 deletions(-) create mode 100644 tests/components/homewizard/fixtures/HWE-P1-zero-values/data.json create mode 100644 tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json create mode 100644 tests/components/homewizard/fixtures/HWE-P1-zero-values/system.json diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 72b1027780a..87235cdb6f2 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -106,7 +106,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_kwh is not None, - value_fn=lambda data: data.total_energy_import_kwh or None, + value_fn=lambda data: data.total_energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -115,7 +115,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_t1_kwh is not None, - value_fn=lambda data: data.total_energy_import_t1_kwh or None, + value_fn=lambda data: data.total_energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", @@ -124,7 +124,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_t2_kwh is not None, - value_fn=lambda data: data.total_energy_import_t2_kwh or None, + value_fn=lambda data: data.total_energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -133,7 +133,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_t3_kwh is not None, - value_fn=lambda data: data.total_energy_import_t3_kwh or None, + value_fn=lambda data: data.total_energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -142,7 +142,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_t4_kwh is not None, - value_fn=lambda data: data.total_energy_import_t4_kwh or None, + value_fn=lambda data: data.total_energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -152,7 +152,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_kwh is not None, enabled_fn=lambda data: data.total_energy_export_kwh != 0, - value_fn=lambda data: data.total_energy_export_kwh or None, + value_fn=lambda data: data.total_energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -162,7 +162,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_t1_kwh is not None, enabled_fn=lambda data: data.total_energy_export_t1_kwh != 0, - value_fn=lambda data: data.total_energy_export_t1_kwh or None, + value_fn=lambda data: data.total_energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -172,7 +172,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_t2_kwh is not None, enabled_fn=lambda data: data.total_energy_export_t2_kwh != 0, - value_fn=lambda data: data.total_energy_export_t2_kwh or None, + value_fn=lambda data: data.total_energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -182,7 +182,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_t3_kwh is not None, enabled_fn=lambda data: data.total_energy_export_t3_kwh != 0, - value_fn=lambda data: data.total_energy_export_t3_kwh or None, + value_fn=lambda data: data.total_energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -192,7 +192,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_t4_kwh is not None, enabled_fn=lambda data: data.total_energy_export_t4_kwh != 0, - value_fn=lambda data: data.total_energy_export_t4_kwh or None, + value_fn=lambda data: data.total_energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -391,7 +391,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_gas_m3 is not None, - value_fn=lambda data: data.total_gas_m3 or None, + value_fn=lambda data: data.total_gas_m3, ), HomeWizardSensorEntityDescription( key="gas_unique_id", @@ -418,7 +418,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_liter_m3 is not None, - value_fn=lambda data: data.total_liter_m3 or None, + value_fn=lambda data: data.total_liter_m3, ), ) diff --git a/tests/components/homewizard/fixtures/HWE-P1-zero-values/data.json b/tests/components/homewizard/fixtures/HWE-P1-zero-values/data.json new file mode 100644 index 00000000000..d21b4ed2d4a --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-zero-values/data.json @@ -0,0 +1,45 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 100, + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "00112233445566778899AABBCCDDEEFF", + "active_tariff": 2, + "total_power_import_kwh": 0.0, + "total_power_import_t1_kwh": 0.0, + "total_power_import_t2_kwh": 0.0, + "total_power_import_t3_kwh": 0.0, + "total_power_import_t4_kwh": 0.0, + "total_power_export_kwh": 0.0, + "total_power_export_t1_kwh": 0.0, + "total_power_export_t2_kwh": 0.0, + "total_power_export_t3_kwh": 0.0, + "total_power_export_t4_kwh": 0.0, + "active_power_w": 0.0, + "active_power_l1_w": 0.0, + "active_power_l2_w": 0.0, + "active_power_l3_w": 0.0, + "active_voltage_l1_v": 0.0, + "active_voltage_l2_v": 0.0, + "active_voltage_l3_v": 0.0, + "active_current_l1_a": 0, + "active_current_l2_a": 0, + "active_current_l3_a": 0, + "active_frequency_hz": 0, + "voltage_sag_l1_count": 0, + "voltage_sag_l2_count": 0, + "voltage_sag_l3_count": 0, + "voltage_swell_l1_count": 0, + "voltage_swell_l2_count": 0, + "voltage_swell_l3_count": 0, + "any_power_fail_count": 0, + "long_power_fail_count": 0, + "total_gas_m3": 0.0, + "gas_timestamp": 210314112233, + "gas_unique_id": "01FFEEDDCCBBAA99887766554433221100", + "active_power_average_w": 0, + "montly_power_peak_w": 0.0, + "montly_power_peak_timestamp": 230101080010, + "active_liter_lpm": 0.0, + "total_liter_m3": 0.0 +} diff --git a/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json b/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json new file mode 100644 index 00000000000..4972c491859 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "3c39e7aabbcc", + "firmware_version": "4.19", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-P1-zero-values/system.json b/tests/components/homewizard/fixtures/HWE-P1-zero-values/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-zero-values/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index a9198ff4337..c2043d593a8 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -3244,7 +3244,7 @@ 'state': '100', }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_active_water_usage:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3267,30 +3267,28 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '2.03', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_active_water_usage:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.device_active_water_usage', + 'entity_id': 'sensor.device_active_average_demand', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3298,33 +3296,32 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:water', - 'original_name': 'Active water usage', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active average demand', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_liter_lpm', - 'unique_id': 'aabbccddeeff_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'translation_key': 'active_power_average_w', + 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unit_of_measurement': , }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_active_water_usage:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', - 'state_class': , - 'unit_of_measurement': 'l/min', + 'device_class': 'power', + 'friendly_name': 'Device Active average demand', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_active_water_usage', + 'entity_id': 'sensor.device_active_average_demand', 'last_changed': , 'last_updated': , 'state': '0', }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3347,22 +3344,22 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '2.03', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'device_class': None, @@ -3370,7 +3367,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.device_total_water_usage', + 'entity_id': 'sensor.device_active_current_phase_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3378,34 +3375,33 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , - 'original_icon': 'mdi:gauge', - 'original_name': 'Total water usage', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 1', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_liter_m3', - 'unique_id': 'aabbccddeeff_total_liter_m3', - 'unit_of_measurement': , + 'translation_key': 'active_current_l1_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'water', - 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 1', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_total_water_usage', + 'entity_id': 'sensor.device_active_current_phase_1', 'last_changed': , 'last_updated': , - 'state': '17.014', + 'state': '0', }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3428,92 +3424,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '2.03', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.device_wi_fi_ssid', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi', - 'original_name': 'Wi-Fi SSID', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', - }), - 'context': , - 'entity_id': 'sensor.device_wi_fi_ssid', - 'last_changed': , - 'last_updated': , - 'state': 'My Wi-Fi', - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '2.03', - 'via_device_id': None, - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3526,8 +3446,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.device_wi_fi_strength', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3535,33 +3455,33 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi', - 'original_name': 'Wi-Fi strength', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 2', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', - 'unit_of_measurement': '%', + 'translation_key': 'active_current_l2_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 2', 'state_class': , - 'unit_of_measurement': '%', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_wi_fi_strength', + 'entity_id': 'sensor.device_active_current_phase_2', 'last_changed': , 'last_updated': , - 'state': '84', + 'state': '0', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3584,16 +3504,176 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l3_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_frequency_hz', + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Active frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_frequency', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3629,7 +3709,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -3641,10 +3721,10 @@ 'entity_id': 'sensor.device_active_power', 'last_changed': , 'last_updated': , - 'state': '-1058.296', + 'state': '0.0', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3667,16 +3747,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3712,7 +3792,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -3724,10 +3804,10 @@ 'entity_id': 'sensor.device_active_power_phase_1', 'last_changed': , 'last_updated': , - 'state': '-1058.296', + 'state': '0.0', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3750,658 +3830,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_export', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy export', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_export', - 'last_changed': , - 'last_updated': , - 'state': '255.551', - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_export_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy export tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_export_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy export tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_export_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '255.551', - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_import', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy import', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy import', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_import', - 'last_changed': , - 'last_updated': , - 'state': '2.705', - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_import_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy import tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy import tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_import_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '2.705', - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.device_wi_fi_ssid', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi', - 'original_name': 'Wi-Fi SSID', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', - }), - 'context': , - 'entity_id': 'sensor.device_wi_fi_ssid', - 'last_changed': , - 'last_updated': , - 'state': 'My Wi-Fi', - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.device_wi_fi_strength', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi', - 'original_name': 'Wi-Fi strength', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.device_wi_fi_strength', - 'last_changed': , - 'last_updated': , - 'state': '92', - }) -# --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_active_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Active power', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'active_power_w', - 'unique_id': 'aabbccddeeff_active_power_w', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Device Active power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_active_power', - 'last_changed': , - 'last_updated': , - 'state': '-900.194', - }) -# --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_active_power_phase_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Active power phase 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'active_power_l1_w', - 'unique_id': 'aabbccddeeff_active_power_l1_w', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Device Active power phase 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_active_power_phase_1', - 'last_changed': , - 'last_updated': , - 'state': '-1058.296', - }) -# --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_2:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_2:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4437,7 +3875,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_2:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -4449,10 +3887,10 @@ 'entity_id': 'sensor.device_active_power_phase_2', 'last_changed': , 'last_updated': , - 'state': '158.102', + 'state': '0.0', }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_3:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4475,16 +3913,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_3:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4520,7 +3958,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_active_power_phase_3:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -4535,7 +3973,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4558,16 +3996,565 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l1_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l2_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l3_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Active water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Active water usage', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_active_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:transmission-tower-off', + 'original_name': 'Long power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'long_power_fail_count', + 'unique_id': 'aabbccddeeff_long_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Long power failures detected', + 'icon': 'mdi:transmission-tower-off', + }), + 'context': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_peak_demand_current_month:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_peak_demand_current_month:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_peak_demand_current_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak demand current month', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_power_peak_w', + 'unique_id': 'aabbccddeeff_monthly_power_peak_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_peak_demand_current_month:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Peak demand current month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_peak_demand_current_month', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_failures_detected:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:transmission-tower-off', + 'original_name': 'Power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'any_power_fail_count', + 'unique_id': 'aabbccddeeff_any_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Power failures detected', + 'icon': 'mdi:transmission-tower-off', + }), + 'context': , + 'entity_id': 'sensor.device_power_failures_detected', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4600,7 +4587,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -4612,10 +4599,10 @@ 'entity_id': 'sensor.device_total_energy_export', 'last_changed': , 'last_updated': , - 'state': '0.523', + 'state': '0.0', }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export_tariff_1:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4638,16 +4625,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export_tariff_1:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4680,7 +4667,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_export_tariff_1:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -4692,10 +4679,10 @@ 'entity_id': 'sensor.device_total_energy_export_tariff_1', 'last_changed': , 'last_updated': , - 'state': '0.523', + 'state': '0.0', }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4718,16 +4705,256 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4760,7 +4987,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -4772,10 +4999,10 @@ 'entity_id': 'sensor.device_total_energy_import', 'last_changed': , 'last_updated': , - 'state': '0.101', + 'state': '0.0', }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import_tariff_1:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4798,16 +5025,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import_tariff_1:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4840,7 +5067,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_total_energy_import_tariff_1:state] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -4852,10 +5079,10 @@ 'entity_id': 'sensor.device_total_energy_import_tariff_1', 'last_changed': , 'last_updated': , - 'state': '0.101', + 'state': '0.0', }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_ssid:device-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4878,16 +5105,1034 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'HWE-P1', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '4.19', 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_ssid:entity-registry] +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_gas:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_m3', + 'unique_id': 'aabbccddeeff_total_gas_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Device Total gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_gas', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'icon': 'mdi:gauge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l1_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 1', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l2_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 2', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l3_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 3', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l1_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 1', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l2_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 2', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l3_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 3', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_active_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_active_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Active water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_active_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Active water usage', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_active_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'icon': 'mdi:gauge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '17.014', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_ssid:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4918,7 +6163,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_ssid:state] +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_ssid:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', @@ -4931,7 +6176,7 @@ 'state': 'My Wi-Fi', }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_strength:device-registry] +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4954,16 +6199,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '2.03', 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_strength:entity-registry] +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_strength:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4996,7 +6241,1457 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[SDM630-entity_ids3][sensor.device_wi_fi_strength:state] +# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '84', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-900.194', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l2_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '158.102', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l3_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_strength:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 6471f89a4de..b2d0fcdb454 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -69,6 +69,45 @@ pytestmark = [ "sensor.device_total_water_usage", ], ), + ( + "HWE-P1-zero-values", + [ + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_long_power_failures_detected", + "sensor.device_active_average_demand", + "sensor.device_peak_demand_current_month", + "sensor.device_total_gas", + "sensor.device_active_water_usage", + "sensor.device_total_water_usage", + ], + ), ( "HWE-WTR", [ From 19040becd37f1569cbb4fd66b5a0f3de817290bb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Nov 2023 21:54:53 +0100 Subject: [PATCH 728/982] Fix hassio mqtt discovery CI test (#104463) * Fix hassio mqtt discovery CI test * Avoid mqtt set up before mocking the flow * Fix mock --- tests/common.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/common.py b/tests/common.py index 06cdee06ec9..30ea779295c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1304,11 +1304,12 @@ async def get_system_health_info(hass: HomeAssistant, domain: str) -> dict[str, @contextmanager def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: """Mock a config flow handler.""" - assert domain not in config_entries.HANDLERS + handler = config_entries.HANDLERS.get(domain) config_entries.HANDLERS[domain] = config_flow _LOGGER.info("Adding mock config flow: %s", domain) yield - config_entries.HANDLERS.pop(domain) + if handler: + config_entries.HANDLERS[domain] = handler def mock_integration( From 0c39c18aafcdb689bc7bed2c623fc3fd4d15c8e4 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Fri, 24 Nov 2023 21:20:09 +0000 Subject: [PATCH 729/982] Bump ring_doorbell to 0.8.2 with listen extra (#104033) --- 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 8abf73e7fed..a20d9b4c90f 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell==0.8.0"] + "requirements": ["ring-doorbell[listen]==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index bdb0fcf2388..558972252ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2351,7 +2351,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.8.0 +ring-doorbell[listen]==0.8.2 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3037c994658..534562ef295 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1757,7 +1757,7 @@ reolink-aio==0.8.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.8.0 +ring-doorbell[listen]==0.8.2 # homeassistant.components.roku rokuecp==0.18.1 From 4860daf1f9a02ecf43d5cb97e16eefd44bd240ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Nov 2023 17:29:19 -0600 Subject: [PATCH 730/982] Bump aioesphomeapi to 18.5.9 (#104465) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a8d8305a7b5..a5856455be5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.5.7", + "aioesphomeapi==18.5.9", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 558972252ae..4f94a72444e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.7 +aioesphomeapi==18.5.9 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 534562ef295..1c50719cca6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.7 +aioesphomeapi==18.5.9 # homeassistant.components.flo aioflo==2021.11.0 From 82734279741bdd438e14473fb58d6dd4bfebf494 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Nov 2023 00:38:39 +0100 Subject: [PATCH 731/982] Complete tests for HomeWizard energy plug HWE-SKT (#104474) --- .../homewizard/snapshots/test_sensor.ambr | 1572 +++++++++++------ tests/components/homewizard/test_sensor.py | 57 + 2 files changed, 1084 insertions(+), 545 deletions(-) diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index c2043d593a8..d4004604f54 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -5939,7 +5939,7 @@ 'state': '0', }) # --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_active_water_usage:device-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -5962,333 +5962,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', + 'model': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '2.03', + 'sw_version': '3.03', 'via_device_id': None, }) # --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_active_water_usage:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_active_water_usage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:water', - 'original_name': 'Active water usage', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'active_liter_lpm', - 'unique_id': 'aabbccddeeff_active_liter_lpm', - 'unit_of_measurement': 'l/min', - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_active_water_usage:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', - 'state_class': , - 'unit_of_measurement': 'l/min', - }), - 'context': , - 'entity_id': 'sensor.device_active_water_usage', - 'last_changed': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_total_water_usage:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '2.03', - 'via_device_id': None, - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_total_water_usage:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_water_usage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:gauge', - 'original_name': 'Total water usage', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_liter_m3', - 'unique_id': 'aabbccddeeff_total_liter_m3', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_total_water_usage:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'water', - 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_water_usage', - 'last_changed': , - 'last_updated': , - 'state': '17.014', - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_ssid:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '2.03', - 'via_device_id': None, - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_ssid:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.device_wi_fi_ssid', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi', - 'original_name': 'Wi-Fi SSID', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_ssid:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', - }), - 'context': , - 'entity_id': 'sensor.device_wi_fi_ssid', - 'last_changed': , - 'last_updated': , - 'state': 'My Wi-Fi', - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_strength:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '2.03', - 'via_device_id': None, - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_strength:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.device_wi_fi_strength', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi', - 'original_name': 'Wi-Fi strength', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensors[HWE-WTR-entity_ids2][sensor.device_wi_fi_strength:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.device_wi_fi_strength', - 'last_changed': , - 'last_updated': , - 'state': '84', - }) -# --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power:entity-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6324,7 +6007,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power:state] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -6336,10 +6019,10 @@ 'entity_id': 'sensor.device_active_power', 'last_changed': , 'last_updated': , - 'state': '-1058.296', + 'state': '1457.277', }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power_phase_1:device-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -6362,16 +6045,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '3.03', 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power_phase_1:entity-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6407,7 +6090,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_active_power_phase_1:state] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -6419,10 +6102,10 @@ 'entity_id': 'sensor.device_active_power_phase_1', 'last_changed': , 'last_updated': , - 'state': '-1058.296', + 'state': '1457.277', }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export:device-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -6445,96 +6128,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '3.03', 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_export', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy export', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_export', - 'last_changed': , - 'last_updated': , - 'state': '255.551', - }) -# --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export_tariff_1:entity-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6567,7 +6170,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_export_tariff_1:state] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -6579,10 +6182,10 @@ 'entity_id': 'sensor.device_total_energy_export_tariff_1', 'last_changed': , 'last_updated': , - 'state': '255.551', + 'state': '0', }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import:device-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -6605,96 +6208,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '3.03', 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_import', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy import', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy import', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_import', - 'last_changed': , - 'last_updated': , - 'state': '2.705', - }) -# --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import_tariff_1:entity-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6727,7 +6250,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_total_energy_import_tariff_1:state] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -6739,10 +6262,10 @@ 'entity_id': 'sensor.device_total_energy_import_tariff_1', 'last_changed': , 'last_updated': , - 'state': '2.705', + 'state': '63.651', }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_ssid:device-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -6765,16 +6288,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '3.03', 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_ssid:entity-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_ssid:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6805,7 +6328,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_ssid:state] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_ssid:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', @@ -6818,7 +6341,7 @@ 'state': 'My Wi-Fi', }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_strength:device-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -6841,16 +6364,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '3.06', + 'sw_version': '3.03', 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_strength:entity-registry] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_strength:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6883,7 +6406,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[SDM230-entity_ids3][sensor.device_wi_fi_strength:state] +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_strength:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', @@ -6895,10 +6418,10 @@ 'entity_id': 'sensor.device_wi_fi_strength', 'last_changed': , 'last_updated': , - 'state': '92', + 'state': '94', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power:device-registry] +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_active_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -6921,7 +6444,324 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_active_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Active water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_active_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Active water usage', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_active_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'icon': 'mdi:gauge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '17.014', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '84', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -6930,7 +6770,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power:entity-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6966,7 +6806,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power:state] +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -6978,10 +6818,10 @@ 'entity_id': 'sensor.device_active_power', 'last_changed': , 'last_updated': , - 'state': '-900.194', + 'state': '-1058.296', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_1:device-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7004,7 +6844,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', + 'model': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -7013,7 +6853,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_1:entity-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7049,7 +6889,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_1:state] +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -7064,7 +6904,483 @@ 'state': '-1058.296', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_2:device-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7096,7 +7412,173 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_2:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-900.194', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7132,7 +7614,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_2:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -7147,7 +7629,7 @@ 'state': '158.102', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_3:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7179,7 +7661,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_3:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7215,7 +7697,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_active_power_phase_3:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -7230,7 +7712,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7262,7 +7744,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7295,7 +7777,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -7310,7 +7792,7 @@ 'state': '0.523', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export_tariff_1:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7342,7 +7824,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export_tariff_1:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7375,7 +7857,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_export_tariff_1:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -7390,7 +7872,7 @@ 'state': '0.523', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7422,7 +7904,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7455,7 +7937,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -7470,7 +7952,7 @@ 'state': '0.101', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import_tariff_1:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7502,7 +7984,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import_tariff_1:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import_tariff_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7535,7 +8017,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_total_energy_import_tariff_1:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import_tariff_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -7550,7 +8032,7 @@ 'state': '0.101', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_ssid:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7582,7 +8064,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_ssid:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7613,7 +8095,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_ssid:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', @@ -7626,7 +8108,7 @@ 'state': 'My Wi-Fi', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_strength:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -7658,7 +8140,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_strength:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7691,7 +8173,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[SDM630-entity_ids4][sensor.device_wi_fi_strength:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index b2d0fcdb454..68616685eeb 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -108,6 +108,17 @@ pytestmark = [ "sensor.device_total_water_usage", ], ), + ( + "HWE-SKT", + [ + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + ], + ), ( "HWE-WTR", [ @@ -193,6 +204,12 @@ async def test_sensors( "sensor.device_total_energy_export_tariff_4", ], ), + ( + "HWE-SKT", + [ + "sensor.device_wi_fi_strength", + ], + ), ( "HWE-WTR", [ @@ -246,6 +263,46 @@ async def test_sensors_unreachable( @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ + ( + "HWE-SKT", + [ + "sensor.device_active_average_demand", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_tariff", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_water_usage", + "sensor.device_dsmr_version", + "sensor.device_gas_meter_identifier", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_gas", + "sensor.device_total_water_usage", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + ], + ), ( "HWE-WTR", [ From b94c9c8f6dd5dcfbe151d1b786476c9ffd1941c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Nov 2023 01:21:25 -0600 Subject: [PATCH 732/982] Bump bluetooth-data-tools to 1.15.0 (#104480) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.14.0...v1.15.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 89e6b350cad..c39c28b13f7 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.14.0", + "bluetooth-data-tools==1.15.0", "dbus-fast==2.14.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a5856455be5..936279668a5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "async-interrupt==1.1.1", "aioesphomeapi==18.5.9", - "bluetooth-data-tools==1.14.0", + "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 7996376b6ac..9fd407b1636 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.14.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.15.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 21543ad6788..6ecd4ed636e 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.14.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.15.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 663461ceaa1..b18716d8020 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.14.0"] + "requirements": ["bluetooth-data-tools==1.15.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4ebc43dc01c..d338d8a8d9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.15.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.5 diff --git a/requirements_all.txt b/requirements_all.txt index 4f94a72444e..e4bbe25ed7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -567,7 +567,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.15.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c50719cca6..c7e5c605351 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -479,7 +479,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.15.0 # homeassistant.components.bond bond-async==0.2.1 From 487ff8cd7f6a8b3076a95e886419367b772999d3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 Nov 2023 08:30:18 +0100 Subject: [PATCH 733/982] Rename ex to exc as name for exceptions (#104479) --- homeassistant/config.py | 64 ++++++++++++++++----------------- homeassistant/config_entries.py | 18 +++++----- homeassistant/core.py | 6 ++-- 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index fc806790bc9..b4850e372fd 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -442,15 +442,15 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: hass.config.path(YAML_CONFIG_FILE), secrets, ) - except HomeAssistantError as ex: - if not (base_ex := ex.__cause__) or not isinstance(base_ex, MarkedYAMLError): + except HomeAssistantError as exc: + if not (base_exc := exc.__cause__) or not isinstance(base_exc, MarkedYAMLError): raise # Rewrite path to offending YAML file to be relative the hass config dir - if base_ex.context_mark and base_ex.context_mark.name: - base_ex.context_mark.name = _relpath(hass, base_ex.context_mark.name) - if base_ex.problem_mark and base_ex.problem_mark.name: - base_ex.problem_mark.name = _relpath(hass, base_ex.problem_mark.name) + if base_exc.context_mark and base_exc.context_mark.name: + base_exc.context_mark.name = _relpath(hass, base_exc.context_mark.name) + if base_exc.problem_mark and base_exc.problem_mark.name: + base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name) raise core_config = config.get(CONF_CORE, {}) @@ -541,32 +541,32 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: @callback def async_log_schema_error( - ex: vol.Invalid, + exc: vol.Invalid, domain: str, config: dict, hass: HomeAssistant, link: str | None = None, ) -> None: """Log a schema validation error.""" - message = format_schema_error(hass, ex, domain, config, link) + message = format_schema_error(hass, exc, domain, config, link) _LOGGER.error(message) @callback def async_log_config_validator_error( - ex: vol.Invalid | HomeAssistantError, + exc: vol.Invalid | HomeAssistantError, domain: str, config: dict, hass: HomeAssistant, link: str | None = None, ) -> None: """Log an error from a custom config validator.""" - if isinstance(ex, vol.Invalid): - async_log_schema_error(ex, domain, config, hass, link) + if isinstance(exc, vol.Invalid): + async_log_schema_error(exc, domain, config, hass, link) return - message = format_homeassistant_error(hass, ex, domain, config, link) - _LOGGER.error(message, exc_info=ex) + message = format_homeassistant_error(hass, exc, domain, config, link) + _LOGGER.error(message, exc_info=exc) def _get_annotation(item: Any) -> tuple[str, int | str] | None: @@ -647,7 +647,7 @@ def _relpath(hass: HomeAssistant, path: str) -> str: def stringify_invalid( hass: HomeAssistant, - ex: vol.Invalid, + exc: vol.Invalid, domain: str, config: dict, link: str | None, @@ -668,26 +668,26 @@ def stringify_invalid( message_suffix = f", please check the docs at {link}" else: message_suffix = "" - if annotation := find_annotation(config, ex.path): + if annotation := find_annotation(config, exc.path): message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" - path = "->".join(str(m) for m in ex.path) - if ex.error_message == "extra keys not allowed": + path = "->".join(str(m) for m in exc.path) + if exc.error_message == "extra keys not allowed": return ( - f"{message_prefix}: '{ex.path[-1]}' is an invalid option for '{domain}', " + f"{message_prefix}: '{exc.path[-1]}' is an invalid option for '{domain}', " f"check: {path}{message_suffix}" ) - if ex.error_message == "required key not provided": + if exc.error_message == "required key not provided": return ( - f"{message_prefix}: required key '{ex.path[-1]}' not provided" + f"{message_prefix}: required key '{exc.path[-1]}' not provided" f"{message_suffix}" ) # This function is an alternative to the stringification done by # vol.Invalid.__str__, so we need to call Exception.__str__ here - # instead of str(ex) - output = Exception.__str__(ex) - if error_type := ex.error_type: + # instead of str(exc) + output = Exception.__str__(exc) + if error_type := exc.error_type: output += " for " + error_type - offending_item_summary = repr(_get_by_path(config, ex.path)) + offending_item_summary = repr(_get_by_path(config, exc.path)) if len(offending_item_summary) > max_sub_error_length: offending_item_summary = ( f"{offending_item_summary[: max_sub_error_length - 3]}..." @@ -728,7 +728,7 @@ def humanize_error( @callback def format_homeassistant_error( hass: HomeAssistant, - ex: HomeAssistantError, + exc: HomeAssistantError, domain: str, config: dict, link: str | None = None, @@ -739,7 +739,7 @@ def format_homeassistant_error( # offending configuration key, use the domain key as path instead. if annotation := find_annotation(config, [domain]): message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" - message = f"{message_prefix}: {str(ex) or repr(ex)}" + message = f"{message_prefix}: {str(exc) or repr(exc)}" if domain != CONF_CORE and link: message += f", please check the docs at {link}" @@ -749,13 +749,13 @@ def format_homeassistant_error( @callback def format_schema_error( hass: HomeAssistant, - ex: vol.Invalid, + exc: vol.Invalid, domain: str, config: dict, link: str | None = None, ) -> str: """Format configuration validation error.""" - return humanize_error(hass, ex, domain, config, link) + return humanize_error(hass, exc, domain, config, link) async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> None: @@ -981,17 +981,17 @@ async def merge_packages_config( hass, domain ) component = integration.get_component() - except LOAD_EXCEPTIONS as ex: + except LOAD_EXCEPTIONS as exc: _log_pkg_error( hass, pack_name, comp_name, config, - f"Integration {comp_name} caused error: {str(ex)}", + f"Integration {comp_name} caused error: {str(exc)}", ) continue - except INTEGRATION_LOAD_EXCEPTIONS as ex: - _log_pkg_error(hass, pack_name, comp_name, config, str(ex)) + except INTEGRATION_LOAD_EXCEPTIONS as exc: + _log_pkg_error(hass, pack_name, comp_name, config, str(exc)) continue try: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2b8f1ec4065..756b2def581 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -406,8 +406,8 @@ class ConfigEntry: "%s.async_setup_entry did not return boolean", integration.domain ) result = False - except ConfigEntryError as ex: - error_reason = str(ex) or "Unknown fatal config entry error" + except ConfigEntryError as exc: + error_reason = str(exc) or "Unknown fatal config entry error" _LOGGER.exception( "Error setting up entry %s for %s: %s", self.title, @@ -416,8 +416,8 @@ class ConfigEntry: ) await self._async_process_on_unload(hass) result = False - except ConfigEntryAuthFailed as ex: - message = str(ex) + except ConfigEntryAuthFailed as exc: + message = str(exc) auth_base_message = "could not authenticate" error_reason = message or auth_base_message auth_message = ( @@ -432,13 +432,13 @@ class ConfigEntry: await self._async_process_on_unload(hass) self.async_start_reauth(hass) result = False - except ConfigEntryNotReady as ex: - self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(ex) or None) + except ConfigEntryNotReady as exc: + self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(exc) or None) wait_time = 2 ** min(self._tries, 4) * 5 + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) self._tries += 1 - message = str(ex) + message = str(exc) ready_message = f"ready yet: {message}" if message else "ready yet" _LOGGER.debug( ( @@ -565,13 +565,13 @@ class ConfigEntry: await self._async_process_on_unload(hass) return result - except Exception as ex: # pylint: disable=broad-except + except Exception as exc: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain ) if integration.domain == self.domain: self._async_set_state( - hass, ConfigEntryState.FAILED_UNLOAD, str(ex) or "Unknown error" + hass, ConfigEntryState.FAILED_UNLOAD, str(exc) or "Unknown error" ) return False diff --git a/homeassistant/core.py b/homeassistant/core.py index a552b53c9c4..972e0e618e6 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -874,8 +874,10 @@ class HomeAssistant: _LOGGER.exception( "Task %s could not be canceled during stage 3 shutdown", task ) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception("Task %s error during stage 3 shutdown: %s", task, ex) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception( + "Task %s error during stage 3 shutdown: %s", task, exc + ) # Prevent run_callback_threadsafe from scheduling any additional # callbacks in the event loop as callbacks created on the futures From 1c4d7e95881a342c3f22d684ef20ab4cb89ddd9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Nov 2023 02:20:56 -0600 Subject: [PATCH 734/982] Improve test coverage for ESPHome deep sleep entities (#104476) --- tests/components/esphome/test_entity.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index fdc57b2dc24..9a5cb441f28 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -13,7 +13,13 @@ from aioesphomeapi import ( UserService, ) -from homeassistant.const import ATTR_RESTORED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_RESTORED, + EVENT_HOMEASSISTANT_STOP, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from .conftest import MockESPHomeDevice @@ -231,6 +237,19 @@ async def test_deep_sleep_device( assert state is not None assert state.state == STATE_UNAVAILABLE + await mock_device.mock_connect() + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + # Verify we do not dispatch any more state updates or + # availability updates after the stop event is fired + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + async def test_esphome_device_without_friendly_name( hass: HomeAssistant, From df37ee4033e1117f5e5a4ae7da9be1007b408cc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Nov 2023 03:41:51 -0600 Subject: [PATCH 735/982] Remove chatty ESPHome state debug logging (#104477) --- homeassistant/components/esphome/entry_data.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 89629a65ea5..d69a30a8c1a 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -321,7 +321,6 @@ class RuntimeEntryData: current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) subscription_key = (state_type, key) - debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if ( current_state == state and subscription_key not in stale_state @@ -333,21 +332,7 @@ class RuntimeEntryData: and (cast(SensorInfo, entity_info)).force_update ) ): - if debug_enabled: - _LOGGER.debug( - "%s: ignoring duplicate update with key %s: %s", - self.name, - key, - state, - ) return - if debug_enabled: - _LOGGER.debug( - "%s: dispatching update with key %s: %s", - self.name, - key, - state, - ) stale_state.discard(subscription_key) current_state_by_type[key] = state if subscription := self.state_subscriptions.get(subscription_key): From 8376a6bda9641a4ae2fc75102639bed38a1c6f17 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 25 Nov 2023 10:44:15 +0100 Subject: [PATCH 736/982] Change to language selector in Workday (#104472) --- .../components/workday/binary_sensor.py | 12 ++++++++ .../components/workday/config_flow.py | 29 ++++++++++++++----- tests/components/workday/__init__.py | 22 ++++++++++++++ .../components/workday/test_binary_sensor.py | 20 +++++++++++++ tests/components/workday/test_config_flow.py | 2 +- 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 26f44fa1e2d..9cc96db7a57 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -83,6 +83,18 @@ async def async_setup_entry( years=year, language=language, ) + if ( + supported_languages := obj_holidays.supported_languages + ) and language == "en": + for lang in supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years=year, + language=lang, + ) + LOGGER.debug("Changing language from %s to %s", language, lang) else: obj_holidays = HolidayBase() diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 1fbeea0684d..348bb0c2fba 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -18,6 +18,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, + LanguageSelector, + LanguageSelectorConfig, NumberSelector, NumberSelectorConfig, NumberSelectorMode, @@ -62,14 +64,14 @@ def add_province_and_language_to_schema( _country = country_holidays(country=country) if country_default_language := (_country.default_language): selectable_languages = _country.supported_languages + new_selectable_languages = [] + for lang in selectable_languages: + new_selectable_languages.append(lang[:2]) language_schema = { vol.Optional( CONF_LANGUAGE, default=country_default_language - ): SelectSelector( - SelectSelectorConfig( - options=list(selectable_languages), - mode=SelectSelectorMode.DROPDOWN, - ) + ): LanguageSelector( + LanguageSelectorConfig(languages=new_selectable_languages) ) } @@ -109,12 +111,25 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: year: int = dt_util.now().year if country := user_input.get(CONF_COUNTRY): + language = user_input.get(CONF_LANGUAGE) + province = user_input.get(CONF_PROVINCE) obj_holidays = country_holidays( country=country, - subdiv=user_input.get(CONF_PROVINCE), + subdiv=province, years=year, - language=user_input.get(CONF_LANGUAGE), + language=language, ) + if ( + supported_languages := obj_holidays.supported_languages + ) and language == "en": + for lang in supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years=year, + language=lang, + ) else: obj_holidays = HolidayBase(years=year) diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index f2744758efb..fb436a57e5c 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -277,3 +277,25 @@ TEST_CONFIG_ADD_REMOVE_DATE_RANGE = { "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], "language": "de", } +TEST_LANGUAGE_CHANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], + "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], + "language": "en", +} +TEST_LANGUAGE_NO_CHANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], + "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], + "language": "de", +} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 6ce5b08ef27..7457d2e0ada 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -35,6 +35,8 @@ from . import ( TEST_CONFIG_WITH_PROVINCE, TEST_CONFIG_WITH_STATE, TEST_CONFIG_YESTERDAY, + TEST_LANGUAGE_CHANGE, + TEST_LANGUAGE_NO_CHANGE, init_integration, ) @@ -313,3 +315,21 @@ async def test_check_date_service( return_response=True, ) assert response == {"binary_sensor.workday_sensor": {"workday": True}} + + +async def test_language_difference_english_language( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling difference in English language naming.""" + await init_integration(hass, TEST_LANGUAGE_CHANGE) + assert "Changing language from en to en_US" in caplog.text + + +async def test_language_difference_no_change_other_language( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test skipping if no difference in language naming.""" + await init_integration(hass, TEST_LANGUAGE_NO_CHANGE) + assert "Changing language from en to en_US" not in caplog.text diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 3ecd518ce98..57a7046546e 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -551,7 +551,7 @@ pytestmark = pytest.mark.usefixtures() ("language", "holiday"), [ ("de", "Weihnachtstag"), - ("en_US", "Christmas"), + ("en", "Christmas"), ], ) async def test_language( From c685d56e8225e1d5939befbea3ae4eecba03b540 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 25 Nov 2023 01:46:49 -0800 Subject: [PATCH 737/982] Add long term statistics for IPP ink/toner levels (#102632) --- homeassistant/components/ipp/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 3bc7035e26b..a2cb5cd34dc 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCATION, PERCENTAGE, EntityCategory @@ -119,6 +120,7 @@ async def async_setup_entry( name=marker.name, icon="mdi:water", native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, attributes_fn=_get_marker_attributes_fn( index, lambda marker: { From ceb26801858699ef8f300f1ba44f5c8bc7b1c9ea Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Sat, 25 Nov 2023 11:34:50 +0100 Subject: [PATCH 738/982] Add available state to OctoPrint camera (#104162) --- homeassistant/components/octoprint/camera.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 99052993a61..a6955706508 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -7,8 +7,8 @@ from homeassistant.components.mjpeg.camera import MjpegCamera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator from .const import DOMAIN @@ -38,7 +38,7 @@ async def async_setup_entry( [ OctoprintCamera( camera_info, - coordinator.device_info, + coordinator, device_id, verify_ssl, ) @@ -46,19 +46,23 @@ async def async_setup_entry( ) -class OctoprintCamera(MjpegCamera): +class OctoprintCamera(CoordinatorEntity[OctoprintDataUpdateCoordinator], MjpegCamera): """Representation of an OctoPrint Camera Stream.""" def __init__( self, camera_settings: WebcamSettings, - device_info: DeviceInfo, + coordinator: OctoprintDataUpdateCoordinator, device_id: str, verify_ssl: bool, ) -> None: """Initialize as a subclass of MjpegCamera.""" super().__init__( - device_info=device_info, + coordinator=coordinator, + ) + MjpegCamera.__init__( + self, + device_info=coordinator.device_info, mjpeg_url=camera_settings.stream_url, name="OctoPrint Camera", still_image_url=camera_settings.external_snapshot_url, From af7155df7ab0d223cd5bf5bb8b0b030d294bccd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 25 Nov 2023 11:41:20 +0100 Subject: [PATCH 739/982] Fix link in Tibber configuration menu (#104322) Co-authored-by: Franck Nijhof --- homeassistant/components/tibber/config_flow.py | 3 +++ homeassistant/components/tibber/strings.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index fbd2345fb80..3fb426d6b11 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -19,6 +19,7 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) ERR_TIMEOUT = "timeout" ERR_CLIENT = "cannot_connect" ERR_TOKEN = "invalid_access_token" +TOKEN_URL = "https://developer.tibber.com/settings/access-token" class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -60,6 +61,7 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, + description_placeholders={"url": TOKEN_URL}, errors=errors, ) @@ -75,5 +77,6 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, + description_placeholders={"url": TOKEN_URL}, errors={}, ) diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 8306f25f587..c7cef9f4657 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -13,7 +13,7 @@ "data": { "access_token": "[%key:common::config_flow::data::access_token%]" }, - "description": "Enter your access token from https://developer.tibber.com/settings/accesstoken" + "description": "Enter your access token from {url}" } } } From 1cfbdd6a5d4efe97ad163181bfb499c593f4da30 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 Nov 2023 05:49:50 -0500 Subject: [PATCH 740/982] Allow overriding blueprints on import (#103340) Co-authored-by: Franck Nijhof --- .../components/automation/helpers.py | 12 ++- homeassistant/components/blueprint/models.py | 36 +++++--- .../components/blueprint/websocket_api.py | 31 ++++++- homeassistant/components/script/helpers.py | 10 ++- tests/components/blueprint/test_models.py | 10 +-- .../blueprint/test_websocket_api.py | 83 +++++++++++++++++++ 6 files changed, 159 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/automation/helpers.py b/homeassistant/components/automation/helpers.py index 7c2efc17bf4..a7c329a544a 100644 --- a/homeassistant/components/automation/helpers.py +++ b/homeassistant/components/automation/helpers.py @@ -1,5 +1,6 @@ """Helpers for automation integration.""" from homeassistant.components import blueprint +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton @@ -15,8 +16,17 @@ def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool: return len(automations_with_blueprint(hass, blueprint_path)) > 0 +async def _reload_blueprint_automations( + hass: HomeAssistant, blueprint_path: str +) -> None: + """Reload all automations that rely on a specific blueprint.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + @singleton(DATA_BLUEPRINTS) @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get automation blueprints.""" - return blueprint.DomainBlueprints(hass, DOMAIN, LOGGER, _blueprint_in_use) + return blueprint.DomainBlueprints( + hass, DOMAIN, LOGGER, _blueprint_in_use, _reload_blueprint_automations + ) diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 6f48080a451..ddf57aa6eee 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Awaitable, Callable import logging import pathlib import shutil @@ -189,12 +189,14 @@ class DomainBlueprints: domain: str, logger: logging.Logger, blueprint_in_use: Callable[[HomeAssistant, str], bool], + reload_blueprint_consumers: Callable[[HomeAssistant, str], Awaitable[None]], ) -> None: """Initialize a domain blueprints instance.""" self.hass = hass self.domain = domain self.logger = logger self._blueprint_in_use = blueprint_in_use + self._reload_blueprint_consumers = reload_blueprint_consumers self._blueprints: dict[str, Blueprint | None] = {} self._load_lock = asyncio.Lock() @@ -283,7 +285,7 @@ class DomainBlueprints: blueprint = await self.hass.async_add_executor_job( self._load_blueprint, blueprint_path ) - except Exception: + except FailedToLoad: self._blueprints[blueprint_path] = None raise @@ -315,31 +317,41 @@ class DomainBlueprints: await self.hass.async_add_executor_job(path.unlink) self._blueprints[blueprint_path] = None - def _create_file(self, blueprint: Blueprint, blueprint_path: str) -> None: - """Create blueprint file.""" + def _create_file( + self, blueprint: Blueprint, blueprint_path: str, allow_override: bool + ) -> bool: + """Create blueprint file. + + Returns true if the action overrides an existing blueprint. + """ path = pathlib.Path( self.hass.config.path(BLUEPRINT_FOLDER, self.domain, blueprint_path) ) - if path.exists(): + exists = path.exists() + + if not allow_override and exists: raise FileAlreadyExists(self.domain, blueprint_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(blueprint.yaml(), encoding="utf-8") + return exists async def async_add_blueprint( - self, blueprint: Blueprint, blueprint_path: str - ) -> None: + self, blueprint: Blueprint, blueprint_path: str, allow_override=False + ) -> bool: """Add a blueprint.""" - if not blueprint_path.endswith(".yaml"): - blueprint_path = f"{blueprint_path}.yaml" - - await self.hass.async_add_executor_job( - self._create_file, blueprint, blueprint_path + overrides_existing = await self.hass.async_add_executor_job( + self._create_file, blueprint, blueprint_path, allow_override ) self._blueprints[blueprint_path] = blueprint + if overrides_existing: + await self._reload_blueprint_consumers(self.hass, blueprint_path) + + return overrides_existing + async def async_populate(self) -> None: """Create folder if it doesn't exist and populate with examples.""" if self._blueprints: diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 1732320c1e9..3c7cc3769c8 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.util import yaml from . import importer, models from .const import DOMAIN -from .errors import FileAlreadyExists +from .errors import FailedToLoad, FileAlreadyExists @callback @@ -81,6 +81,23 @@ async def ws_import_blueprint( ) return + # Check it exists and if so, which automations are using it + domain = imported_blueprint.blueprint.metadata["domain"] + domain_blueprints: models.DomainBlueprints | None = hass.data.get(DOMAIN, {}).get( + domain + ) + if domain_blueprints is None: + connection.send_error( + msg["id"], websocket_api.ERR_INVALID_FORMAT, "Unsupported domain" + ) + return + + suggested_path = f"{imported_blueprint.suggested_filename}.yaml" + try: + exists = bool(await domain_blueprints.async_get_blueprint(suggested_path)) + except FailedToLoad: + exists = False + connection.send_result( msg["id"], { @@ -90,6 +107,7 @@ async def ws_import_blueprint( "metadata": imported_blueprint.blueprint.metadata, }, "validation_errors": imported_blueprint.blueprint.validate(), + "exists": exists, }, ) @@ -101,6 +119,7 @@ async def ws_import_blueprint( vol.Required("path"): cv.path, vol.Required("yaml"): cv.string, vol.Optional("source_url"): cv.url, + vol.Optional("allow_override"): bool, } ) @websocket_api.async_response @@ -130,8 +149,13 @@ async def ws_save_blueprint( connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) return + if not path.endswith(".yaml"): + path = f"{path}.yaml" + try: - await domain_blueprints[domain].async_add_blueprint(blueprint, path) + overrides_existing = await domain_blueprints[domain].async_add_blueprint( + blueprint, path, allow_override=msg.get("allow_override", False) + ) except FileAlreadyExists: connection.send_error(msg["id"], "already_exists", "File already exists") return @@ -141,6 +165,9 @@ async def ws_save_blueprint( connection.send_result( msg["id"], + { + "overrides_existing": overrides_existing, + }, ) diff --git a/homeassistant/components/script/helpers.py b/homeassistant/components/script/helpers.py index 9f0d4399d3d..4504869e270 100644 --- a/homeassistant/components/script/helpers.py +++ b/homeassistant/components/script/helpers.py @@ -1,5 +1,6 @@ """Helpers for automation integration.""" from homeassistant.components.blueprint import DomainBlueprints +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton @@ -15,8 +16,15 @@ def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool: return len(scripts_with_blueprint(hass, blueprint_path)) > 0 +async def _reload_blueprint_scripts(hass: HomeAssistant, blueprint_path: str) -> None: + """Reload all script that rely on a specific blueprint.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + @singleton(DATA_BLUEPRINTS) @callback def async_get_blueprints(hass: HomeAssistant) -> DomainBlueprints: """Get script blueprints.""" - return DomainBlueprints(hass, DOMAIN, LOGGER, _blueprint_in_use) + return DomainBlueprints( + hass, DOMAIN, LOGGER, _blueprint_in_use, _reload_blueprint_scripts + ) diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index b2d3ce517d8..c11a467de9b 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -1,6 +1,6 @@ """Test blueprint models.""" import logging -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -49,7 +49,7 @@ def blueprint_2(): def domain_bps(hass): """Domain blueprints fixture.""" return models.DomainBlueprints( - hass, "automation", logging.getLogger(__name__), None + hass, "automation", logging.getLogger(__name__), None, AsyncMock() ) @@ -257,13 +257,9 @@ async def test_domain_blueprints_inputs_from_config(domain_bps, blueprint_1) -> async def test_domain_blueprints_add_blueprint(domain_bps, blueprint_1) -> None: """Test DomainBlueprints.async_add_blueprint.""" with patch.object(domain_bps, "_create_file") as create_file_mock: - # Should add extension when not present. - await domain_bps.async_add_blueprint(blueprint_1, "something") + await domain_bps.async_add_blueprint(blueprint_1, "something.yaml") assert create_file_mock.call_args[0][1] == "something.yaml" - await domain_bps.async_add_blueprint(blueprint_1, "something2.yaml") - assert create_file_mock.call_args[0][1] == "something2.yaml" - # Should be in cache. with patch.object(domain_bps, "_load_blueprint") as mock_load: assert await domain_bps.async_get_blueprint("something.yaml") == blueprint_1 diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index f831445b60c..213dff89597 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import Mock, patch import pytest +import yaml from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -129,6 +130,52 @@ async def test_import_blueprint( }, }, "validation_errors": None, + "exists": False, + } + + +async def test_import_blueprint_update( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, + setup_bp, +) -> None: + """Test importing blueprints.""" + raw_data = Path( + hass.config.path("blueprints/automation/in_folder/in_folder_blueprint.yaml") + ).read_text() + + aioclient_mock.get( + "https://raw.githubusercontent.com/in_folder/home-assistant-config/main/blueprints/automation/in_folder_blueprint.yaml", + text=raw_data, + ) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "blueprint/import", + "url": "https://github.com/in_folder/home-assistant-config/blob/main/blueprints/automation/in_folder_blueprint.yaml", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == { + "suggested_filename": "in_folder/in_folder_blueprint", + "raw_data": raw_data, + "blueprint": { + "metadata": { + "domain": "automation", + "input": {"action": None, "trigger": None}, + "name": "In Folder Blueprint", + "source_url": "https://github.com/in_folder/home-assistant-config/blob/main/blueprints/automation/in_folder_blueprint.yaml", + } + }, + "validation_errors": None, + "exists": True, } @@ -212,6 +259,42 @@ async def test_save_existing_file( assert msg["error"] == {"code": "already_exists", "message": "File already exists"} +async def test_save_existing_file_override( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test saving blueprints.""" + + client = await hass_ws_client(hass) + with patch("pathlib.Path.write_text") as write_mock: + await client.send_json( + { + "id": 7, + "type": "blueprint/save", + "path": "test_event_service", + "yaml": 'blueprint: {name: "name", domain: "automation"}', + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/test_event_service.yaml", + "allow_override": True, + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 7 + assert msg["success"] + assert msg["result"] == {"overrides_existing": True} + assert yaml.safe_load(write_mock.mock_calls[0][1][0]) == { + "blueprint": { + "name": "name", + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/test_event_service.yaml", + "input": {}, + } + } + + async def test_save_file_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 17cef8940fa1aaf906efe0429f05feea31d66eb7 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 25 Nov 2023 13:11:29 +0100 Subject: [PATCH 741/982] Add translation keys to ViCare integration (#104425) Co-authored-by: Franck Nijhof --- .../components/vicare/binary_sensor.py | 34 +-- homeassistant/components/vicare/button.py | 20 +- homeassistant/components/vicare/climate.py | 22 +- homeassistant/components/vicare/number.py | 15 +- homeassistant/components/vicare/sensor.py | 146 +++++----- homeassistant/components/vicare/strings.json | 260 ++++++++++++++++++ .../components/vicare/water_heater.py | 22 +- 7 files changed, 385 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 9e9f133b730..e9e379f45f2 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -5,6 +5,7 @@ from contextlib import suppress from dataclasses import dataclass import logging +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, @@ -40,14 +41,14 @@ class ViCareBinarySensorEntityDescription( CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="circulationpump_active", - name="Circulation pump", + translation_key="circulation_pump", icon="mdi:pump", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getCirculationPumpActive(), ), ViCareBinarySensorEntityDescription( key="frost_protection_active", - name="Frost protection", + translation_key="frost_protection", icon="mdi:snowflake", value_getter=lambda api: api.getFrostProtectionActive(), ), @@ -56,7 +57,7 @@ CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( BURNER_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="burner_active", - name="Burner", + translation_key="burner", icon="mdi:gas-burner", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getActive(), @@ -66,7 +67,7 @@ BURNER_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( COMPRESSOR_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="compressor_active", - name="Compressor", + translation_key="compressor", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getActive(), ), @@ -75,27 +76,27 @@ COMPRESSOR_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="solar_pump_active", - name="Solar pump", + translation_key="solar_pump", icon="mdi:pump", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getSolarPumpActive(), ), ViCareBinarySensorEntityDescription( key="charging_active", - name="DHW Charging", + translation_key="domestic_hot_water_charging", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterChargingActive(), ), ViCareBinarySensorEntityDescription( key="dhw_circulationpump_active", - name="DHW Circulation Pump", + translation_key="domestic_hot_water_circulation_pump", icon="mdi:pump", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterCirculationPumpActive(), ), ViCareBinarySensorEntityDescription( key="dhw_pump_active", - name="DHW Pump", + translation_key="domestic_hot_water_pump", icon="mdi:pump", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterPumpActive(), @@ -104,15 +105,13 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( def _build_entity( - name: str, - vicare_api, + vicare_api: PyViCareDevice, device_config: PyViCareDeviceConfig, entity_description: ViCareBinarySensorEntityDescription, ): """Create a ViCare binary sensor entity.""" - if is_supported(name, entity_description, vicare_api): + if is_supported(entity_description.key, entity_description, vicare_api): return ViCareBinarySensor( - name, vicare_api, device_config, entity_description, @@ -130,12 +129,8 @@ async def _entities_from_descriptions( """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: for current in iterables: - suffix = "" - if len(iterables) > 1: - suffix = f" {current.id}" entity = await hass.async_add_executor_job( _build_entity, - f"{description.name}{suffix}", current, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -157,7 +152,6 @@ async def async_setup_entry( for description in GLOBAL_SENSORS: entity = await hass.async_add_executor_job( _build_entity, - description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -195,12 +189,14 @@ class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): entity_description: ViCareBinarySensorEntityDescription def __init__( - self, name, api, device_config, description: ViCareBinarySensorEntityDescription + self, + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, + description: ViCareBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(device_config, api, description.key) self.entity_description = description - self._attr_name = name @property def available(self) -> bool: diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 5ea6cf5edae..54b183bcc33 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -5,6 +5,7 @@ from contextlib import suppress from dataclasses import dataclass import logging +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, @@ -26,8 +27,6 @@ from .utils import is_supported _LOGGER = logging.getLogger(__name__) -BUTTON_DHW_ACTIVATE_ONETIME_CHARGE = "activate_onetimecharge" - @dataclass class ViCareButtonEntityDescription( @@ -38,8 +37,8 @@ class ViCareButtonEntityDescription( BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( ViCareButtonEntityDescription( - key=BUTTON_DHW_ACTIVATE_ONETIME_CHARGE, - name="Activate one-time charge", + key="activate_onetimecharge", + translation_key="activate_onetimecharge", icon="mdi:shower-head", entity_category=EntityCategory.CONFIG, value_getter=lambda api: api.getOneTimeCharge(), @@ -49,16 +48,13 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( def _build_entity( - name: str, - vicare_api, + vicare_api: PyViCareDevice, device_config: PyViCareDeviceConfig, entity_description: ViCareButtonEntityDescription, ): """Create a ViCare button entity.""" - _LOGGER.debug("Found device %s", name) - if is_supported(name, entity_description, vicare_api): + if is_supported(entity_description.key, entity_description, vicare_api): return ViCareButton( - name, vicare_api, device_config, entity_description, @@ -79,7 +75,6 @@ async def async_setup_entry( for description in BUTTON_DESCRIPTIONS: entity = await hass.async_add_executor_job( _build_entity, - description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -96,7 +91,10 @@ class ViCareButton(ViCareEntity, ButtonEntity): entity_description: ViCareButtonEntityDescription def __init__( - self, name, api, device_config, description: ViCareButtonEntityDescription + self, + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, + description: ViCareButtonEntityDescription, ) -> None: """Initialize the button.""" super().__init__(device_config, api, description.key) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 0c145e5e5a8..fa41242036a 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -5,6 +5,9 @@ from contextlib import suppress import logging from typing import Any +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit from PyViCare.PyViCareUtils import ( PyViCareCommandError, PyViCareInvalidDataError, @@ -107,18 +110,15 @@ async def async_setup_entry( """Set up the ViCare climate platform.""" entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] circuits = await hass.async_add_executor_job(_get_circuits, api) for circuit in circuits: - suffix = "" - if len(circuits) > 1: - suffix = f" {circuit.id}" - entity = ViCareClimate( - f"Heating{suffix}", api, circuit, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], + device_config, + "heating", ) entities.append(entity) @@ -148,13 +148,19 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _current_action: bool | None = None _current_mode: str | None = None - def __init__(self, name, api, circuit, device_config) -> None: + def __init__( + self, + api: PyViCareDevice, + circuit: PyViCareHeatingCircuit, + device_config: PyViCareDeviceConfig, + translation_key: str, + ) -> None: """Initialize the climate device.""" super().__init__(device_config, api, circuit.id) - self._attr_name = name self._circuit = circuit self._attributes: dict[str, Any] = {} self._current_program = None + self._attr_translation_key = translation_key def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 17017d00def..c5d800cd4ac 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -43,7 +43,7 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( ViCareNumberEntityDescription( key="heating curve shift", - name="Heating curve shift", + translation_key="heating curve shift", icon="mdi:plus-minus-variant", entity_category=EntityCategory.CONFIG, value_getter=lambda api: api.getHeatingCurveShift(), @@ -57,7 +57,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( ), ViCareNumberEntityDescription( key="heating curve slope", - name="Heating curve slope", + translation_key="heating_curve_slope", icon="mdi:slope-uphill", entity_category=EntityCategory.CONFIG, value_getter=lambda api: api.getHeatingCurveSlope(), @@ -72,16 +72,13 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( def _build_entity( - name: str, vicare_api: PyViCareHeatingDeviceWithComponent, device_config: PyViCareDeviceConfig, entity_description: ViCareNumberEntityDescription, ) -> ViCareNumber | None: """Create a ViCare number entity.""" - _LOGGER.debug("Found device %s", name) - if is_supported(name, entity_description, vicare_api): + if is_supported(entity_description.key, entity_description, vicare_api): return ViCareNumber( - name, vicare_api, device_config, entity_description, @@ -100,13 +97,9 @@ async def async_setup_entry( entities: list[ViCareNumber] = [] try: for circuit in api.circuits: - suffix = "" - if len(api.circuits) > 1: - suffix = f" {circuit.id}" for description in CIRCUIT_ENTITY_DESCRIPTIONS: entity = await hass.async_add_executor_job( _build_entity, - f"{description.name}{suffix}", circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -126,7 +119,6 @@ class ViCareNumber(ViCareEntity, NumberEntity): def __init__( self, - name: str, api: PyViCareHeatingDeviceWithComponent, device_config: PyViCareDeviceConfig, description: ViCareNumberEntityDescription, @@ -134,7 +126,6 @@ class ViCareNumber(ViCareEntity, NumberEntity): """Initialize the number.""" super().__init__(device_config, api, description.key) self.entity_description = description - self._attr_name = name @property def available(self) -> bool: diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index ae83459ddda..cc147f695c6 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -65,7 +65,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="outside_temperature", - name="Outside Temperature", + translation_key="outside_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getOutsideTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -73,7 +73,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="return_temperature", - name="Return Temperature", + translation_key="return_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getReturnTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -81,7 +81,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="boiler_temperature", - name="Boiler Temperature", + translation_key="boiler_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getBoilerTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -89,7 +89,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="boiler_supply_temperature", - name="Boiler Supply Temperature", + translation_key="boiler_supply_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getBoilerCommonSupplyTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -97,7 +97,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="primary_circuit_supply_temperature", - name="Primary Circuit Supply Temperature", + translation_key="primary_circuit_supply_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSupplyTemperaturePrimaryCircuit(), device_class=SensorDeviceClass.TEMPERATURE, @@ -105,7 +105,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="primary_circuit_return_temperature", - name="Primary Circuit Return Temperature", + translation_key="primary_circuit_return_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getReturnTemperaturePrimaryCircuit(), device_class=SensorDeviceClass.TEMPERATURE, @@ -113,7 +113,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="secondary_circuit_supply_temperature", - name="Secondary Circuit Supply Temperature", + translation_key="secondary_circuit_supply_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSupplyTemperatureSecondaryCircuit(), device_class=SensorDeviceClass.TEMPERATURE, @@ -121,7 +121,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="secondary_circuit_return_temperature", - name="Secondary Circuit Return Temperature", + translation_key="secondary_circuit_return_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getReturnTemperatureSecondaryCircuit(), device_class=SensorDeviceClass.TEMPERATURE, @@ -129,7 +129,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_out_temperature", - name="Hot Water Out Temperature", + translation_key="hotwater_out_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getDomesticHotWaterOutletTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -137,7 +137,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_max_temperature", - name="Hot Water Max Temperature", + translation_key="hotwater_max_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getDomesticHotWaterMaxTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -145,7 +145,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_min_temperature", - name="Hot Water Min Temperature", + translation_key="hotwater_min_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getDomesticHotWaterMinTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -153,63 +153,63 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_today", - name="Hot water gas consumption today", + translation_key="hotwater_gas_consumption_today", value_getter=lambda api: api.getGasConsumptionDomesticHotWaterToday(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_heating_this_week", - name="Hot water gas consumption this week", + translation_key="hotwater_gas_consumption_heating_this_week", value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisWeek(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_heating_this_month", - name="Hot water gas consumption this month", + translation_key="hotwater_gas_consumption_heating_this_month", value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisMonth(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_heating_this_year", - name="Hot water gas consumption this year", + translation_key="hotwater_gas_consumption_heating_this_year", value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisYear(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_consumption_heating_today", - name="Heating gas consumption today", + translation_key="gas_consumption_heating_today", value_getter=lambda api: api.getGasConsumptionHeatingToday(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_consumption_heating_this_week", - name="Heating gas consumption this week", + translation_key="gas_consumption_heating_this_week", value_getter=lambda api: api.getGasConsumptionHeatingThisWeek(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_consumption_heating_this_month", - name="Heating gas consumption this month", + translation_key="gas_consumption_heating_this_month", value_getter=lambda api: api.getGasConsumptionHeatingThisMonth(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_consumption_heating_this_year", - name="Heating gas consumption this year", + translation_key="gas_consumption_heating_this_year", value_getter=lambda api: api.getGasConsumptionHeatingThisYear(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentday", - name="Heating gas consumption current day", + translation_key="gas_summary_consumption_heating_currentday", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionHeatingCurrentDay(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), @@ -217,7 +217,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentmonth", - name="Heating gas consumption current month", + translation_key="gas_summary_consumption_heating_currentmonth", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionHeatingCurrentMonth(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), @@ -225,7 +225,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentyear", - name="Heating gas consumption current year", + translation_key="gas_summary_consumption_heating_currentyear", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionHeatingCurrentYear(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), @@ -233,7 +233,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_lastsevendays", - name="Heating gas consumption last seven days", + translation_key="gas_summary_consumption_heating_lastsevendays", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionHeatingLastSevenDays(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), @@ -241,7 +241,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentday", - name="Hot water gas consumption current day", + translation_key="hotwater_gas_summary_consumption_heating_currentday", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterCurrentDay(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), @@ -249,7 +249,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentmonth", - name="Hot water gas consumption current month", + translation_key="hotwater_gas_summary_consumption_heating_currentmonth", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterCurrentMonth(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), @@ -257,7 +257,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentyear", - name="Hot water gas consumption current year", + translation_key="hotwater_gas_summary_consumption_heating_currentyear", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterCurrentYear(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), @@ -265,7 +265,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_lastsevendays", - name="Hot water gas consumption last seven days", + translation_key="hotwater_gas_summary_consumption_heating_lastsevendays", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterLastSevenDays(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), @@ -273,7 +273,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_currentday", - name="Energy consumption of gas heating current day", + translation_key="energy_summary_consumption_heating_currentday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionHeatingCurrentDay(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), @@ -281,7 +281,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_currentmonth", - name="Energy consumption of gas heating current month", + translation_key="energy_summary_consumption_heating_currentmonth", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionHeatingCurrentMonth(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), @@ -289,7 +289,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_currentyear", - name="Energy consumption of gas heating current year", + translation_key="energy_summary_consumption_heating_currentyear", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionHeatingCurrentYear(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), @@ -297,7 +297,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_lastsevendays", - name="Energy consumption of gas heating last seven days", + translation_key="energy_summary_consumption_heating_lastsevendays", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionHeatingLastSevenDays(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), @@ -305,7 +305,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_dhw_summary_consumption_heating_currentday", - name="Energy consumption of hot water gas heating current day", + translation_key="energy_dhw_summary_consumption_heating_currentday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterCurrentDay(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), @@ -313,7 +313,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_dhw_summary_consumption_heating_currentmonth", - name="Energy consumption of hot water gas heating current month", + translation_key="energy_dhw_summary_consumption_heating_currentmonth", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterCurrentMonth(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), @@ -321,7 +321,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_dhw_summary_consumption_heating_currentyear", - name="Energy consumption of hot water gas heating current year", + translation_key="energy_dhw_summary_consumption_heating_currentyear", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterCurrentYear(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), @@ -329,7 +329,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_dhw_consumption_heating_lastsevendays", - name="Energy consumption of hot water gas heating last seven days", + translation_key="energy_summary_dhw_consumption_heating_lastsevendays", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterLastSevenDays(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), @@ -337,7 +337,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_current", - name="Power production current", + translation_key="power_production_current", native_unit_of_measurement=UnitOfPower.WATT, value_getter=lambda api: api.getPowerProductionCurrent(), device_class=SensorDeviceClass.POWER, @@ -345,7 +345,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_today", - name="Energy production today", + translation_key="power_production_today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerProductionToday(), device_class=SensorDeviceClass.ENERGY, @@ -353,7 +353,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_this_week", - name="Energy production this week", + translation_key="power_production_this_week", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerProductionThisWeek(), device_class=SensorDeviceClass.ENERGY, @@ -361,7 +361,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_this_month", - name="Energy production this month", + translation_key="power_production_this_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerProductionThisMonth(), device_class=SensorDeviceClass.ENERGY, @@ -369,7 +369,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_this_year", - name="Energy production this year", + translation_key="power_production_this_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerProductionThisYear(), device_class=SensorDeviceClass.ENERGY, @@ -377,7 +377,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar storage temperature", - name="Solar Storage Temperature", + translation_key="solar_storage_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSolarStorageTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -385,7 +385,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="collector temperature", - name="Solar Collector Temperature", + translation_key="collector_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSolarCollectorTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -393,7 +393,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar power production today", - name="Solar energy production today", + translation_key="solar_power_production_today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getSolarPowerProductionToday(), unit_getter=lambda api: api.getSolarPowerProductionUnit(), @@ -402,7 +402,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar power production this week", - name="Solar energy production this week", + translation_key="solar_power_production_this_week", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getSolarPowerProductionThisWeek(), unit_getter=lambda api: api.getSolarPowerProductionUnit(), @@ -411,7 +411,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar power production this month", - name="Solar energy production this month", + translation_key="solar_power_production_this_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getSolarPowerProductionThisMonth(), unit_getter=lambda api: api.getSolarPowerProductionUnit(), @@ -420,7 +420,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar power production this year", - name="Solar energy production this year", + translation_key="solar_power_production_this_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getSolarPowerProductionThisYear(), unit_getter=lambda api: api.getSolarPowerProductionUnit(), @@ -429,7 +429,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power consumption today", - name="Energy consumption today", + translation_key="power_consumption_today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerConsumptionToday(), unit_getter=lambda api: api.getPowerConsumptionUnit(), @@ -438,7 +438,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power consumption this week", - name="Power consumption this week", + translation_key="power_consumption_this_week", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerConsumptionThisWeek(), unit_getter=lambda api: api.getPowerConsumptionUnit(), @@ -447,7 +447,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power consumption this month", - name="Energy consumption this month", + translation_key="power consumption this month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerConsumptionThisMonth(), unit_getter=lambda api: api.getPowerConsumptionUnit(), @@ -456,7 +456,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power consumption this year", - name="Energy consumption this year", + translation_key="power_consumption_this_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerConsumptionThisYear(), unit_getter=lambda api: api.getPowerConsumptionUnit(), @@ -465,7 +465,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="buffer top temperature", - name="Buffer top temperature", + translation_key="buffer_top_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getBufferTopTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -473,7 +473,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="buffer main temperature", - name="Buffer main temperature", + translation_key="buffer_main_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getBufferMainTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -481,7 +481,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="volumetric_flow", - name="Volumetric flow", + translation_key="volumetric_flow", icon="mdi:gauge", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, value_getter=lambda api: api.getVolumetricFlowReturn() / 1000, @@ -493,7 +493,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="supply_temperature", - name="Supply Temperature", + translation_key="supply_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSupplyTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -504,14 +504,14 @@ CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="burner_starts", - name="Burner Starts", + translation_key="burner_starts", icon="mdi:counter", value_getter=lambda api: api.getStarts(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="burner_hours", - name="Burner Hours", + translation_key="burner_hours", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHours(), @@ -519,7 +519,7 @@ BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="burner_modulation", - name="Burner Modulation", + translation_key="burner_modulation", icon="mdi:percent", native_unit_of_measurement=PERCENTAGE, value_getter=lambda api: api.getModulation(), @@ -530,14 +530,14 @@ BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="compressor_starts", - name="Compressor Starts", + translation_key="compressor_starts", icon="mdi:counter", value_getter=lambda api: api.getStarts(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="compressor_hours", - name="Compressor Hours", + translation_key="compressor_hours", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHours(), @@ -545,7 +545,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass1", - name="Compressor Hours Load Class 1", + translation_key="compressor_hours_loadclass1", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass1(), @@ -553,7 +553,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass2", - name="Compressor Hours Load Class 2", + translation_key="compressor_hours_loadclass2", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass2(), @@ -561,7 +561,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass3", - name="Compressor Hours Load Class 3", + translation_key="compressor_hours_loadclass3", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass3(), @@ -569,7 +569,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass4", - name="Compressor Hours Load Class 4", + translation_key="compressor_hours_loadclass4", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass4(), @@ -577,7 +577,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass5", - name="Compressor Hours Load Class 5", + translation_key="compressor_hours_loadclass5", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass5(), @@ -585,7 +585,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_phase", - name="Compressor Phase", + translation_key="compressor_phase", icon="mdi:information", value_getter=lambda api: api.getPhase(), entity_category=EntityCategory.DIAGNOSTIC, @@ -594,16 +594,13 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( def _build_entity( - name: str, vicare_api, device_config: PyViCareDeviceConfig, entity_description: ViCareSensorEntityDescription, ): """Create a ViCare sensor entity.""" - _LOGGER.debug("Found device %s", name) - if is_supported(name, entity_description, vicare_api): + if is_supported(entity_description.key, entity_description, vicare_api): return ViCareSensor( - name, vicare_api, device_config, entity_description, @@ -621,12 +618,8 @@ async def _entities_from_descriptions( """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: for current in iterables: - suffix = "" - if len(iterables) > 1: - suffix = f" {current.id}" entity = await hass.async_add_executor_job( _build_entity, - f"{description.name}{suffix}", current, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -647,7 +640,6 @@ async def async_setup_entry( for description in GLOBAL_SENSORS: entity = await hass.async_add_executor_job( _build_entity, - description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -685,12 +677,14 @@ class ViCareSensor(ViCareEntity, SensorEntity): entity_description: ViCareSensorEntityDescription def __init__( - self, name, api, device_config, description: ViCareSensorEntityDescription + self, + api, + device_config: PyViCareDeviceConfig, + description: ViCareSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(device_config, api, description.key) self.entity_description = description - self._attr_name = name @property def available(self) -> bool: diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 2dc1eecd1e4..f3a51bde9e4 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -28,6 +28,266 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, + "entity": { + "binary_sensor": { + "circulation_pump": { + "name": "Circulation pump" + }, + "frost_protection": { + "name": "Frost protection" + }, + "burner": { + "name": "Burner" + }, + "compressor": { + "name": "Compressor" + }, + "solar_pump": { + "name": "Solar pump" + }, + "domestic_hot_water_charging": { + "name": "DHW charging" + }, + "domestic_hot_water_circulation_pump": { + "name": "DHW circulation pump" + }, + "domestic_hot_water_pump": { + "name": "DHW pump" + } + }, + "button": { + "activate_onetimecharge": { + "name": "Activate one-time charge" + } + }, + "climate": { + "heating": { + "name": "Heating" + } + }, + "number": { + "heating_curve_shift": { + "name": "Heating curve shift" + }, + "heating_curve_slope": { + "name": "Heating curve slope" + }, + "normal_temperature": { + "name": "Normal temperature" + }, + "reduced_temperature": { + "name": "Reduced temperature" + }, + "comfort_temperature": { + "name": "Comfort temperature" + }, + "eco_temperature": { + "name": "Eco temperature" + } + }, + "sensor": { + "outside_temperature": { + "name": "Outside temperature" + }, + "return_temperature": { + "name": "Return temperature" + }, + "boiler_temperature": { + "name": "Boiler temperature" + }, + "boiler_supply_temperature": { + "name": "Boiler supply temperature" + }, + "primary_circuit_supply_temperature": { + "name": "Primary circuit supply temperature" + }, + "primary_circuit_return_temperature": { + "name": "Primary circuit return temperature" + }, + "secondary_circuit_supply_temperature": { + "name": "Secondary circuit supply temperature" + }, + "secondary_circuit_return_temperature": { + "name": "Secondary circuit return temperature" + }, + "hotwater_out_temperature": { + "name": "DHW out temperature" + }, + "hotwater_max_temperature": { + "name": "DHW max temperature" + }, + "hotwater_min_temperature": { + "name": "DHW min temperature" + }, + "hotwater_gas_consumption_today": { + "name": "DHW gas consumption today" + }, + "hotwater_gas_consumption_heating_this_week": { + "name": "DHW gas consumption this week" + }, + "hotwater_gas_consumption_heating_this_month": { + "name": "DHW gas consumption this month" + }, + "hotwater_gas_consumption_heating_this_year": { + "name": "DHW gas consumption this year" + }, + "gas_consumption_heating_today": { + "name": "Heating gas consumption today" + }, + "gas_consumption_heating_this_week": { + "name": "Heating gas consumption this week" + }, + "gas_consumption_heating_this_month": { + "name": "Heating gas consumption this month" + }, + "gas_consumption_heating_this_year": { + "name": "Heating gas consumption this year" + }, + "gas_summary_consumption_heating_currentday": { + "name": "Heating gas consumption current day" + }, + "gas_summary_consumption_heating_currentmonth": { + "name": "Heating gas consumption current month" + }, + "gas_summary_consumption_heating_currentyear": { + "name": "Heating gas consumption current year" + }, + "gas_summary_consumption_heating_lastsevendays": { + "name": "Heating gas consumption last seven days" + }, + "hotwater_gas_summary_consumption_heating_currentday": { + "name": "DHW gas consumption current day" + }, + "hotwater_gas_summary_consumption_heating_currentmonth": { + "name": "DHW gas consumption current month" + }, + "hotwater_gas_summary_consumption_heating_currentyear": { + "name": "DHW gas consumption current year" + }, + "hotwater_gas_summary_consumption_heating_lastsevendays": { + "name": "DHW gas consumption last seven days" + }, + "energy_summary_consumption_heating_currentday": { + "name": "Energy consumption of gas heating current day" + }, + "energy_summary_consumption_heating_currentmonth": { + "name": "Energy consumption of gas heating current month" + }, + "energy_summary_consumption_heating_currentyear": { + "name": "Energy consumption of gas heating current year" + }, + "energy_summary_consumption_heating_lastsevendays": { + "name": "Energy consumption of gas heating last seven days" + }, + "energy_dhw_summary_consumption_heating_currentday": { + "name": "Energy consumption of hot water gas heating current day" + }, + "energy_dhw_summary_consumption_heating_currentmonth": { + "name": "Energy consumption of hot water gas heating current month" + }, + "energy_dhw_summary_consumption_heating_currentyear": { + "name": "Energy consumption of hot water gas heating current year" + }, + "energy_summary_dhw_consumption_heating_lastsevendays": { + "name": "Energy consumption of hot water gas heating last seven days" + }, + "power_production_current": { + "name": "Power production current" + }, + "power_production_today": { + "name": "Energy production today" + }, + "power_production_this_week": { + "name": "Energy production this week" + }, + "power_production_this_month": { + "name": "Energy production this month" + }, + "power_production_this_year": { + "name": "Energy production this year" + }, + "solar_storage_temperature": { + "name": "Solar storage temperature" + }, + "collector_temperature": { + "name": "Solar collector temperature" + }, + "solar_power_production_today": { + "name": "Solar energy production today" + }, + "solar_power_production_this_week": { + "name": "Solar energy production this week" + }, + "solar_power_production_this_month": { + "name": "Solar energy production this month" + }, + "solar_power_production_this_year": { + "name": "Solar energy production this year" + }, + "power_consumption_today": { + "name": "Energy consumption today" + }, + "power_consumption_this_week": { + "name": "Power consumption this week" + }, + "power_consumption_this_month": { + "name": "Energy consumption this month" + }, + "power_consumption_this_year": { + "name": "Energy consumption this year" + }, + "buffer_top_temperature": { + "name": "Buffer top temperature" + }, + "buffer_main_temperature": { + "name": "Buffer main temperature" + }, + "volumetric_flow": { + "name": "Volumetric flow" + }, + "supply_temperature": { + "name": "Supply temperature" + }, + "burner_starts": { + "name": "Burner starts" + }, + "burner_hours": { + "name": "Burner hours" + }, + "burner_modulation": { + "name": "Burner modulation" + }, + "compressor_starts": { + "name": "Compressor starts" + }, + "compressor_hours": { + "name": "Compressor hours" + }, + "compressor_hours_loadclass1": { + "name": "Compressor hours load class 1" + }, + "compressor_hours_loadclass2": { + "name": "Compressor hours load class 2" + }, + "compressor_hours_loadclass3": { + "name": "Compressor hours load class 3" + }, + "compressor_hours_loadclass4": { + "name": "Compressor hours load class 4" + }, + "compressor_hours_loadclass5": { + "name": "Compressor hours load class 5" + }, + "compressor_phase": { + "name": "Compressor phase" + } + }, + "water_heater": { + "water": { + "name": "Water" + } + } + }, "services": { "set_vicare_mode": { "name": "Set ViCare mode", diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 0e927a24650..570474f4186 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -3,6 +3,9 @@ from contextlib import suppress import logging from typing import Any +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -71,18 +74,15 @@ async def async_setup_entry( """Set up the ViCare climate platform.""" entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] circuits = await hass.async_add_executor_job(_get_circuits, api) for circuit in circuits: - suffix = "" - if len(circuits) > 1: - suffix = f" {circuit.id}" - entity = ViCareWater( - f"Water{suffix}", api, circuit, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], + device_config, + "water", ) entities.append(entity) @@ -99,13 +99,19 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity): _attr_max_temp = VICARE_TEMP_WATER_MAX _attr_operation_list = list(HA_TO_VICARE_HVAC_DHW) - def __init__(self, name, api, circuit, device_config) -> None: + def __init__( + self, + api: PyViCareDevice, + circuit: PyViCareHeatingCircuit, + device_config: PyViCareDeviceConfig, + translation_key: str, + ) -> None: """Initialize the DHW water_heater device.""" super().__init__(device_config, api, circuit.id) - self._attr_name = name self._circuit = circuit self._attributes: dict[str, Any] = {} self._current_mode = None + self._attr_translation_key = translation_key def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" From dd028220baeff35878db92aa104cd2b7f9e69659 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sat, 25 Nov 2023 15:05:08 +0200 Subject: [PATCH 742/982] Use iso8601 format when fetching prayer times (#104458) --- .../islamic_prayer_times/coordinator.py | 6 ++- .../islamic_prayer_times/__init__.py | 48 ++++++------------- .../islamic_prayer_times/test_init.py | 9 ++-- .../islamic_prayer_times/test_sensor.py | 8 +--- 4 files changed, 27 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 161ce7b2644..aedaf43411a 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -77,6 +77,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim midnightMode=self.midnight_mode, school=self.school, date=str(dt_util.now().date()), + iso8601=True, ) return cast(dict[str, Any], calc.fetch_prayer_times()) @@ -145,9 +146,12 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim async_call_later(self.hass, 60, self.async_request_update) raise UpdateFailed from err + # introduced in prayer-times-calculator 0.0.8 + prayer_times.pop("date", None) + prayer_times_info: dict[str, datetime] = {} for prayer, time in prayer_times.items(): - if prayer_time := dt_util.parse_datetime(f"{dt_util.now().date()} {time}"): + if prayer_time := dt_util.parse_datetime(time): prayer_times_info[prayer] = dt_util.as_utc(prayer_time) self.async_schedule_future_update(prayer_times_info["Midnight"]) diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index b93c46108d8..8750461c47f 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -5,43 +5,23 @@ from datetime import datetime import homeassistant.util.dt as dt_util PRAYER_TIMES = { - "Fajr": "06:10", - "Sunrise": "07:25", - "Dhuhr": "12:30", - "Asr": "15:32", - "Maghrib": "17:35", - "Isha": "18:53", - "Midnight": "00:45", -} - -PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 1, 6, 10, 0, tzinfo=dt_util.UTC), - "Sunrise": datetime(2020, 1, 1, 7, 25, 0, tzinfo=dt_util.UTC), - "Dhuhr": datetime(2020, 1, 1, 12, 30, 0, tzinfo=dt_util.UTC), - "Asr": datetime(2020, 1, 1, 15, 32, 0, tzinfo=dt_util.UTC), - "Maghrib": datetime(2020, 1, 1, 17, 35, 0, tzinfo=dt_util.UTC), - "Isha": datetime(2020, 1, 1, 18, 53, 0, tzinfo=dt_util.UTC), - "Midnight": datetime(2020, 1, 1, 00, 45, 0, tzinfo=dt_util.UTC), + "Fajr": "2020-01-01T06:10:00+00:00", + "Sunrise": "2020-01-01T07:25:00+00:00", + "Dhuhr": "2020-01-01T12:30:00+00:00", + "Asr": "2020-01-01T15:32:00+00:00", + "Maghrib": "2020-01-01T17:35:00+00:00", + "Isha": "2020-01-01T18:53:00+00:00", + "Midnight": "2020-01-01T00:45:00+00:00", } NEW_PRAYER_TIMES = { - "Fajr": "06:00", - "Sunrise": "07:25", - "Dhuhr": "12:30", - "Asr": "15:32", - "Maghrib": "17:45", - "Isha": "18:53", - "Midnight": "00:43", -} - -NEW_PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 2, 6, 00, 0, tzinfo=dt_util.UTC), - "Sunrise": datetime(2020, 1, 2, 7, 25, 0, tzinfo=dt_util.UTC), - "Dhuhr": datetime(2020, 1, 2, 12, 30, 0, tzinfo=dt_util.UTC), - "Asr": datetime(2020, 1, 2, 15, 32, 0, tzinfo=dt_util.UTC), - "Maghrib": datetime(2020, 1, 2, 17, 45, 0, tzinfo=dt_util.UTC), - "Isha": datetime(2020, 1, 2, 18, 53, 0, tzinfo=dt_util.UTC), - "Midnight": datetime(2020, 1, 2, 00, 43, 0, tzinfo=dt_util.UTC), + "Fajr": "2020-01-02T06:00:00+00:00", + "Sunrise": "2020-01-02T07:25:00+00:00", + "Dhuhr": "2020-01-02T12:30:00+00:00", + "Asr": "2020-01-02T15:32:00+00:00", + "Maghrib": "2020-01-02T17:45:00+00:00", + "Isha": "2020-01-02T18:53:00+00:00", + "Midnight": "2020-01-02T00:43:00+00:00", } NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 0a41630e29b..0c3f19e43fe 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -13,8 +13,9 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util -from . import NEW_PRAYER_TIMES, NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS +from . import NEW_PRAYER_TIMES, NOW, PRAYER_TIMES from tests.common import MockConfigEntry, async_fire_time_changed @@ -90,7 +91,7 @@ async def test_options_listener(hass: HomeAssistant) -> None: with patch( "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, - ) as mock_fetch_prayer_times: + ) as mock_fetch_prayer_times, freeze_time(NOW): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert mock_fetch_prayer_times.call_count == 1 @@ -123,7 +124,9 @@ async def test_update_failed(hass: HomeAssistant) -> None: InvalidResponseError, NEW_PRAYER_TIMES, ] - future = PRAYER_TIMES_TIMESTAMPS["Midnight"] + timedelta(days=1, minutes=1) + midnight_time = dt_util.parse_datetime(PRAYER_TIMES["Midnight"]) + assert midnight_time + future = midnight_time + timedelta(days=1, minutes=1) with freeze_time(future): async_fire_time_changed(hass, future) await hass.async_block_till_done() diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index e7f3759f993..164ac8818fe 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -6,9 +6,8 @@ import pytest from homeassistant.components.islamic_prayer_times.const import DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util -from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS +from . import NOW, PRAYER_TIMES from tests.common import MockConfigEntry @@ -44,7 +43,4 @@ async def test_islamic_prayer_times_sensors( ), freeze_time(NOW): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( - hass.states.get(sensor_name).state - == PRAYER_TIMES_TIMESTAMPS[key].astimezone(dt_util.UTC).isoformat() - ) + assert hass.states.get(sensor_name).state == PRAYER_TIMES[key] From e821ff8b490448ec9cfaeb1c5c8ba50cac3489bc Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Sun, 26 Nov 2023 00:42:53 +1100 Subject: [PATCH 743/982] Bump aiolifx and aiolifx-themes to support new LIFX devices (#104498) --- homeassistant/components/lifx/manifest.json | 6 ++++-- homeassistant/generated/zeroconf.py | 8 ++++++++ requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 7cabfd4712f..39412780331 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -29,9 +29,11 @@ "LIFX GU10", "LIFX Lightstrip", "LIFX Mini", + "LIFX Neon", "LIFX Nightvision", "LIFX Pls", "LIFX Plus", + "LIFX String", "LIFX Tile", "LIFX White", "LIFX Z" @@ -40,8 +42,8 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==0.8.10", + "aiolifx==1.0.0", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.4.5" + "aiolifx-themes==0.4.10" ] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3c828a54faf..06daf8bc4a8 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -116,6 +116,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Neon": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Nightvision": { "always_discover": True, "domain": "lifx", @@ -128,6 +132,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX String": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Tile": { "always_discover": True, "domain": "lifx", diff --git a/requirements_all.txt b/requirements_all.txt index e4bbe25ed7f..6ddb9fc7f29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -282,10 +282,10 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.4.5 +aiolifx-themes==0.4.10 # homeassistant.components.lifx -aiolifx==0.8.10 +aiolifx==1.0.0 # homeassistant.components.livisi aiolivisi==0.0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7e5c605351..750e0df3ee4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -255,10 +255,10 @@ aiokafka==0.7.2 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.4.5 +aiolifx-themes==0.4.10 # homeassistant.components.lifx -aiolifx==0.8.10 +aiolifx==1.0.0 # homeassistant.components.livisi aiolivisi==0.0.19 From eb472d9f71c8bf44c075458d091a0c93ce326ccc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Nov 2023 14:43:30 +0100 Subject: [PATCH 744/982] Update sentry-sdk to 1.37.1 (#104499) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 081f56fe5f6..2af110564e7 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.36.0"] + "requirements": ["sentry-sdk==1.37.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6ddb9fc7f29..8b2464d477e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2430,7 +2430,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.36.0 +sentry-sdk==1.37.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 750e0df3ee4..f87498c1af5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1809,7 +1809,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.36.0 +sentry-sdk==1.37.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 71268bd407b5f399aa69e58e8111fa244130bf06 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sat, 25 Nov 2023 07:50:44 -0600 Subject: [PATCH 745/982] Add HassClimateGetTemperature intent (#102831) --- homeassistant/components/climate/intent.py | 68 +++++++ tests/components/climate/test_intent.py | 221 +++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 homeassistant/components/climate/intent.py create mode 100644 tests/components/climate/test_intent.py diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py new file mode 100644 index 00000000000..23cc3d5bcd2 --- /dev/null +++ b/homeassistant/components/climate/intent.py @@ -0,0 +1,68 @@ +"""Intents for the client integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import intent +from homeassistant.helpers.entity_component import EntityComponent + +from . import DOMAIN, ClimateEntity + +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the climate intents.""" + intent.async_register(hass, GetTemperatureIntent()) + + +class GetTemperatureIntent(intent.IntentHandler): + """Handle GetTemperature intents.""" + + intent_type = INTENT_GET_TEMPERATURE + slot_schema = {vol.Optional("area"): str} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + component: EntityComponent[ClimateEntity] = hass.data[DOMAIN] + entities: list[ClimateEntity] = list(component.entities) + climate_entity: ClimateEntity | None = None + climate_state: State | None = None + + if not entities: + raise intent.IntentHandleError("No climate entities") + + if "area" in slots: + # Filter by area + area_name = slots["area"]["value"] + + for maybe_climate in intent.async_match_states( + hass, area_name=area_name, domains=[DOMAIN] + ): + climate_state = maybe_climate + break + + if climate_state is None: + raise intent.IntentHandleError(f"No climate entity in area {area_name}") + + climate_entity = component.get_entity(climate_state.entity_id) + else: + # First entity + climate_entity = entities[0] + climate_state = hass.states.get(climate_entity.entity_id) + + assert climate_entity is not None + + if climate_state is None: + raise intent.IntentHandleError(f"No state for {climate_entity.name}") + + assert climate_state is not None + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=[climate_state]) + return response diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py new file mode 100644 index 00000000000..eaf7029d303 --- /dev/null +++ b/tests/components/climate/test_intent.py @@ -0,0 +1,221 @@ +"""Test climate intents.""" +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + DOMAIN, + ClimateEntity, + HVACMode, + intent as climate_intent, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[ClimateEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + 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() + + return config_entry + + +class MockClimateEntity(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_get_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent.""" + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # First climate entity will be selected (no area) + response = await intent.async_handle( + hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by area instead (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": "Bedroom"}}, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + +async def test_get_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateGetTemperature intent with no climate entities.""" + await climate_intent.async_setup_intents(hass) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + ) + + +async def test_get_temperature_no_state( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent when states are missing.""" + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + entity_registry.async_get_or_create( + DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + await create_mock_platform(hass, [climate_1]) + + living_room_area = area_registry.async_create(name="Living Room") + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + + with patch("homeassistant.core.StateMachine.get", return_value=None), pytest.raises( + intent.IntentHandleError + ): + await intent.async_handle( + hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + ) + + with patch( + "homeassistant.core.StateMachine.async_all", return_value=[] + ), pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": "Living Room"}}, + ) From 03caa21a51f4a017ce478bcd75e39626232d32ca Mon Sep 17 00:00:00 2001 From: cronjefourie Date: Sat, 25 Nov 2023 16:47:03 +0200 Subject: [PATCH 746/982] Add additional sensors for Tuya DIN (#98752) Co-authored-by: Franck Nijhof --- homeassistant/components/tuya/sensor.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 9f055a6262e..313900fab4e 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -818,6 +818,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfElectricPotential.VOLT, subkey="voltage", ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo From 48f8cec84bc3a7a901530632e3a4d75dc7c9d969 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 25 Nov 2023 15:47:45 +0100 Subject: [PATCH 747/982] Add reuse functions to access circuits, burners and compressors in ViCare integration (#104371) --- .../components/vicare/binary_sensor.py | 29 ++++++--------- homeassistant/components/vicare/climate.py | 16 +++------ homeassistant/components/vicare/sensor.py | 36 ++++++++----------- homeassistant/components/vicare/utils.py | 31 ++++++++++++++++ .../components/vicare/water_heater.py | 12 ++----- 5 files changed, 62 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index e9e379f45f2..3b49fcb9134 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity -from .utils import is_supported +from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) @@ -159,26 +159,17 @@ async def async_setup_entry( if entity is not None: entities.append(entity) - try: - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") + await _entities_from_descriptions( + hass, entities, CIRCUIT_SENSORS, get_circuits(api), config_entry + ) - try: - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, api.burners, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No burners found") + await _entities_from_descriptions( + hass, entities, BURNER_SENSORS, get_burners(api), config_entry + ) - try: - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No compressors found") + await _entities_from_descriptions( + hass, entities, COMPRESSOR_SENSORS, get_compressors(api), config_entry + ) async_add_entities(entities) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index fa41242036a..97df501f80b 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -40,6 +40,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity +from .utils import get_burners, get_circuits, get_compressors _LOGGER = logging.getLogger(__name__) @@ -93,15 +94,6 @@ HA_TO_VICARE_PRESET_HEATING = { } -def _get_circuits(vicare_api): - """Return the list of circuits.""" - try: - return vicare_api.circuits - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") - return [] - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -111,7 +103,7 @@ async def async_setup_entry( entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(_get_circuits, api) + circuits = get_circuits(api) for circuit in circuits: entity = ViCareClimate( @@ -213,11 +205,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._current_action = False # Update the specific device attributes with suppress(PyViCareNotSupportedFeatureError): - for burner in self._api.burners: + for burner in get_burners(self._api): self._current_action = self._current_action or burner.getActive() with suppress(PyViCareNotSupportedFeatureError): - for compressor in self._api.compressors: + for compressor in get_compressors(self._api): self._current_action = ( self._current_action or compressor.getActive() ) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index cc147f695c6..a3f05cfc84b 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -45,7 +45,7 @@ from .const import ( VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, ) from .entity import ViCareEntity -from .utils import is_supported +from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) @@ -634,39 +634,33 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + api: Device = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config: PyViCareDeviceConfig = hass.data[DOMAIN][config_entry.entry_id][ + VICARE_DEVICE_CONFIG + ] entities = [] for description in GLOBAL_SENSORS: entity = await hass.async_add_executor_job( _build_entity, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], + device_config, description, ) if entity is not None: entities.append(entity) - try: - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") + await _entities_from_descriptions( + hass, entities, CIRCUIT_SENSORS, get_circuits(api), config_entry + ) - try: - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, api.burners, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No burners found") + await _entities_from_descriptions( + hass, entities, BURNER_SENSORS, get_burners(api), config_entry + ) - try: - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No compressors found") + await _entities_from_descriptions( + hass, entities, COMPRESSOR_SENSORS, get_compressors(api), config_entry + ) async_add_entities(entities) diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 19a75c00962..5b3fb38337f 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -1,6 +1,10 @@ """ViCare helpers functions.""" import logging +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, +) from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from . import ViCareRequiredKeysMixin @@ -24,3 +28,30 @@ def is_supported( _LOGGER.debug("Attribute Error %s: %s", name, error) return False return True + + +def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of burners.""" + try: + return device.burners + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No burners found") + return [] + + +def get_circuits(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of circuits.""" + try: + return device.circuits + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No circuits found") + return [] + + +def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of compressors.""" + try: + return device.compressors + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No compressors found") + return [] diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 570474f4186..9b33ca9947a 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -24,6 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity +from .utils import get_circuits _LOGGER = logging.getLogger(__name__) @@ -57,15 +58,6 @@ HA_TO_VICARE_HVAC_DHW = { } -def _get_circuits(vicare_api): - """Return the list of circuits.""" - try: - return vicare_api.circuits - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") - return [] - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -75,7 +67,7 @@ async def async_setup_entry( entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(_get_circuits, api) + circuits = get_circuits(api) for circuit in circuits: entity = ViCareWater( From 837f34c40c2ded83220f4b2c5cba78e10d2f150f Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Sat, 25 Nov 2023 11:14:48 -0800 Subject: [PATCH 748/982] Add scene.delete service for dynamically created scenes (with scene.create) (#89090) * Added scene.delete service Only for scenes created with scene.create * Refactor after #95984 #96390 * Split scene validation in 2 First, check if entity_id is a scene Second, check if it's a scene created with `scene.create` * Address feedback - Move service to `homeassistant` domain - Register with `platform.async_register_entity_service` - Raise validation errors instead of just logging messages * Revert moving the service to the `homeassistant` domain * Remove unneeded validation * Use helpers and fix tests * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Fix linting --------- Co-authored-by: Martin Hjelmare --- .../components/homeassistant/scene.py | 43 ++++++++++++- homeassistant/components/scene/services.yaml | 6 ++ homeassistant/components/scene/strings.json | 12 ++++ tests/components/homeassistant/test_scene.py | 60 +++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 258970378b2..3308083f22f 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -29,14 +29,17 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_per_platform, config_validation as cv, entity_platform, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform -from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.service import ( + async_extract_entity_ids, + async_register_admin_service, +) from homeassistant.helpers.state import async_reproduce_state from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration @@ -125,6 +128,7 @@ CREATE_SCENE_SCHEMA = vol.All( SERVICE_APPLY = "apply" SERVICE_CREATE = "create" +SERVICE_DELETE = "delete" _LOGGER = logging.getLogger(__name__) @@ -273,6 +277,41 @@ async def async_setup_platform( SCENE_DOMAIN, SERVICE_CREATE, create_service, CREATE_SCENE_SCHEMA ) + async def delete_service(call: ServiceCall) -> None: + """Delete a dynamically created scene.""" + entity_ids = await async_extract_entity_ids(hass, call) + + for entity_id in entity_ids: + scene = platform.entities.get(entity_id) + if scene is None: + raise ServiceValidationError( + f"{entity_id} is not a valid scene entity_id", + translation_domain=SCENE_DOMAIN, + translation_key="entity_not_scene", + translation_placeholders={ + "entity_id": entity_id, + }, + ) + assert isinstance(scene, HomeAssistantScene) + if not scene.from_service: + raise ServiceValidationError( + f"The scene {entity_id} is not created with service `scene.create`", + translation_domain=SCENE_DOMAIN, + translation_key="entity_not_dynamically_created", + translation_placeholders={ + "entity_id": entity_id, + }, + ) + + await platform.async_remove_entity(entity_id) + + hass.services.async_register( + SCENE_DOMAIN, + SERVICE_DELETE, + delete_service, + cv.make_entity_service_schema({}), + ) + def _process_scenes_config( hass: HomeAssistant, async_add_entities: AddEntitiesCallback, config: dict[str, Any] diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index 543cefd5b9a..a2139529ccf 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -54,3 +54,9 @@ create: selector: entity: multiple: true + +delete: + target: + entity: + - integration: homeassistant + domain: scene diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json index 3bfea1b09e7..af91b2e227e 100644 --- a/homeassistant/components/scene/strings.json +++ b/homeassistant/components/scene/strings.json @@ -46,6 +46,18 @@ "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`." } } + }, + "delete": { + "name": "Delete", + "description": "Deletes a dynamically created scene." + } + }, + "exceptions": { + "entity_not_scene": { + "message": "{entity_id} is not a valid scene entity_id." + }, + "entity_not_dynamically_created": { + "message": "The scene {entity_id} is not created with service `scene.create`." } } } diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py index 085ed4f0641..d754c67ad49 100644 --- a/tests/components/homeassistant/test_scene.py +++ b/tests/components/homeassistant/test_scene.py @@ -8,6 +8,7 @@ from homeassistant.components.homeassistant import scene as ha_scene from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import async_capture_events, async_mock_service @@ -164,6 +165,65 @@ async def test_create_service( assert scene.attributes.get("entity_id") == ["light.kitchen"] +async def test_delete_service( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the delete service.""" + assert await async_setup_component( + hass, + "scene", + {"scene": {"name": "hallo_2", "entities": {"light.kitchen": "on"}}}, + ) + + await hass.services.async_call( + "scene", + "create", + { + "scene_id": "hallo", + "entities": {"light.bed_light": {"state": "on", "brightness": 50}}, + }, + blocking=True, + ) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "scene", + "delete", + { + "entity_id": "scene.hallo_3", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "scene", + "delete", + { + "entity_id": "scene.hallo_2", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get("scene.hallo_2") is not None + + assert hass.states.get("scene.hallo") is not None + + await hass.services.async_call( + "scene", + "delete", + { + "entity_id": "scene.hallo", + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("state.hallo") is None + + async def test_snapshot_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From fc5ae50e066d46d5f75df04f30503e964eebeff1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Nov 2023 14:00:04 -0600 Subject: [PATCH 749/982] Bump aioesphomeapi to 19.0.0 (#104512) --- homeassistant/components/esphome/manager.py | 17 +++++++- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/test_manager.py | 39 ++++++++++++++++++- 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 85c311ecc81..79e8a0a06fa 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -27,7 +27,12 @@ import voluptuous as vol from homeassistant.components import tag, zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DEVICE_ID, CONF_MODE, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_MODE, + EVENT_HOMEASSISTANT_STOP, + EVENT_LOGGING_CHANGED, +) from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -545,6 +550,11 @@ class ESPHomeManager: ): self.entry.async_start_reauth(self.hass) + @callback + def _async_handle_logging_changed(self, _event: Event) -> None: + """Handle when the logging level changes.""" + self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG)) + async def async_start(self) -> None: """Start the esphome connection manager.""" hass = self.hass @@ -561,6 +571,11 @@ class ESPHomeManager: entry_data.cleanup_callbacks.append( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.on_stop) ) + entry_data.cleanup_callbacks.append( + hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_handle_logging_changed + ) + ) reconnect_logic = ReconnectLogic( client=self.cli, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 936279668a5..54f9c235188 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.5.9", + "aioesphomeapi==19.0.0", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 8b2464d477e..0400d49e40e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.9 +aioesphomeapi==19.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f87498c1af5..125e89e0e18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.5.9 +aioesphomeapi==19.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index d297dddee4a..244e7487ed3 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,6 +1,6 @@ """Test ESPHome manager.""" from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, call from aioesphomeapi import APIClient, DeviceInfo, EntityInfo, EntityState, UserService import pytest @@ -16,6 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice @@ -332,3 +333,39 @@ async def test_connection_aborted_wrong_device( await hass.async_block_till_done() assert len(new_info.mock_calls) == 1 assert "Unexpected device found at" not in caplog.text + + +async def test_debug_logging( + mock_client: APIClient, + hass: HomeAssistant, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], +) -> None: + """Test enabling and disabling debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "DEBUG"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_client.set_debug.assert_has_calls([call(True)]) + + mock_client.reset_mock() + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "WARNING"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_client.set_debug.assert_has_calls([call(False)]) From 86b172037bc47f20411e74659807ab52511b16f3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 25 Nov 2023 21:28:49 +0100 Subject: [PATCH 750/982] Add address to error text in modbus (#104520) --- homeassistant/components/modbus/modbus.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 764cf4930f7..c0474ad75d5 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -435,16 +435,24 @@ class ModbusHub: try: result: ModbusResponse = entry.func(address, value, **kwargs) except ModbusException as exception_error: - self._log_error(str(exception_error)) + error = ( + f"Error: device: {slave} address: {address} -> {str(exception_error)}" + ) + self._log_error(error) return None if not result: - self._log_error("Error: pymodbus returned None") + error = ( + f"Error: device: {slave} address: {address} -> pymodbus returned None" + ) + self._log_error(error) return None if not hasattr(result, entry.attr): - self._log_error(str(result)) + error = f"Error: device: {slave} address: {address} -> {str(result)}" + self._log_error(error) return None if result.isError(): - self._log_error("Error: pymodbus returned isError True") + error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True" + self._log_error(error) return None self._in_error = False return result From 498bea09f24f6bba16d410dbf70e0d28ebde0f71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Nov 2023 15:46:19 -0600 Subject: [PATCH 751/982] Bump aioesphomeapi to 19.0.1 (#104527) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 54f9c235188..60f34c23779 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==19.0.0", + "aioesphomeapi==19.0.1", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 0400d49e40e..8034e6a315b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.0.0 +aioesphomeapi==19.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 125e89e0e18..33aa0cbd9a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.0.0 +aioesphomeapi==19.0.1 # homeassistant.components.flo aioflo==2021.11.0 From 76f78d7747a768ac4afde46941898264b1438353 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sun, 26 Nov 2023 01:01:53 +0100 Subject: [PATCH 752/982] Bump PyViCare to 2.29.0 (#104516) * Update requirements_all.txt * Update requirements_test_all.txt * Update manifest.json --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index d71ccdbb12c..cbde6242082 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.28.1"] + "requirements": ["PyViCare==2.29.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8034e6a315b..cbf7105f99f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -112,7 +112,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.28.1 +PyViCare==2.29.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33aa0cbd9a7..eef0c3f4242 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.28.1 +PyViCare==2.29.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 8ffad6f7a689e2c8a2a652628a9c35ff0445a269 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 26 Nov 2023 03:01:45 +0100 Subject: [PATCH 753/982] Bump aiowithings to 1.0.3 (#104530) --- 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 d43ae7da50c..e2357e78fb8 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==1.0.2"] + "requirements": ["aiowithings==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index cbf7105f99f..2217fb723ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -392,7 +392,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.2 +aiowithings==1.0.3 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eef0c3f4242..89f8b2d43cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -365,7 +365,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.2 +aiowithings==1.0.3 # homeassistant.components.yandex_transport aioymaps==1.2.2 From b42629ecf3cf6355a747bfdc5bca0aea8b6bf27c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 26 Nov 2023 03:51:55 +0100 Subject: [PATCH 754/982] Update nibe heatpump dependency to 2.5.2 (#104526) Bump nibe to 2.5.2 --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 76341eca627..94a2a76c814 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.5.1"] + "requirements": ["nibe==2.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2217fb723ff..7dee603a176 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1316,7 +1316,7 @@ nextcord==2.0.0a8 nextdns==2.0.1 # homeassistant.components.nibe_heatpump -nibe==2.5.1 +nibe==2.5.2 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89f8b2d43cb..b56d662f9aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1031,7 +1031,7 @@ nextcord==2.0.0a8 nextdns==2.0.1 # homeassistant.components.nibe_heatpump -nibe==2.5.1 +nibe==2.5.2 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From a074c06f9289bb57e786a821d43ee040dabc6a97 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 26 Nov 2023 04:08:20 -0500 Subject: [PATCH 755/982] Add alert to zwave_js device info page for custom device config (#104115) --- homeassistant/components/zwave_js/api.py | 13 ++++++++----- tests/components/zwave_js/test_api.py | 5 +++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index a917aa44889..9e50b55830c 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -393,7 +393,7 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_subscribe_node_status) websocket_api.async_register_command(hass, websocket_node_status) websocket_api.async_register_command(hass, websocket_node_metadata) - websocket_api.async_register_command(hass, websocket_node_comments) + websocket_api.async_register_command(hass, websocket_node_alerts) websocket_api.async_register_command(hass, websocket_add_node) websocket_api.async_register_command(hass, websocket_grant_security_classes) websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) @@ -616,22 +616,25 @@ async def websocket_node_metadata( @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/node_comments", + vol.Required(TYPE): "zwave_js/node_alerts", vol.Required(DEVICE_ID): str, } ) @websocket_api.async_response @async_get_node -async def websocket_node_comments( +async def websocket_node_alerts( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], node: Node, ) -> None: - """Get the comments of a Z-Wave JS node.""" + """Get the alerts for a Z-Wave JS node.""" connection.send_result( msg[ID], - {"comments": node.device_config.metadata.comments}, + { + "comments": node.device_config.metadata.comments, + "is_embedded": node.device_config.is_embedded, + }, ) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 9c4a6339a78..aa20bd3bb84 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -457,7 +457,7 @@ async def test_node_metadata( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_node_comments( +async def test_node_alerts( hass: HomeAssistant, wallmote_central_scene, integration, @@ -473,13 +473,14 @@ async def test_node_comments( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/node_comments", + TYPE: "zwave_js/node_alerts", DEVICE_ID: device.id, } ) msg = await ws_client.receive_json() result = msg["result"] assert result["comments"] == [{"level": "info", "text": "test"}] + assert result["is_embedded"] async def test_add_node( From e2e58c44952d8290064b32503b1a6df7c9cb9eb5 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sun, 26 Nov 2023 10:49:06 +0100 Subject: [PATCH 756/982] Fix translation key in ViCare integration (#104536) fix translation key --- homeassistant/components/vicare/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index c5d800cd4ac..cfbe13e1267 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -43,7 +43,7 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( ViCareNumberEntityDescription( key="heating curve shift", - translation_key="heating curve shift", + translation_key="heating_curve_shift", icon="mdi:plus-minus-variant", entity_category=EntityCategory.CONFIG, value_getter=lambda api: api.getHeatingCurveShift(), From bd27358398390edc4a5815970c3e2e7dec39ded1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 26 Nov 2023 11:09:14 +0100 Subject: [PATCH 757/982] Adjust HomeWizard test fixtures to match actual devices (#104537) --- .../homewizard/fixtures/HWE-SKT/data.json | 40 +---- .../homewizard/fixtures/HWE-WTR/data.json | 42 +---- .../homewizard/fixtures/SDM230/data.json | 40 +---- .../homewizard/fixtures/SDM630/data.json | 38 +---- .../snapshots/test_diagnostics.ambr | 4 +- .../homewizard/snapshots/test_sensor.ambr | 160 ++++++++++++++++++ tests/components/homewizard/test_sensor.py | 4 +- 7 files changed, 168 insertions(+), 160 deletions(-) diff --git a/tests/components/homewizard/fixtures/HWE-SKT/data.json b/tests/components/homewizard/fixtures/HWE-SKT/data.json index 7e647952982..f2a465bd40d 100644 --- a/tests/components/homewizard/fixtures/HWE-SKT/data.json +++ b/tests/components/homewizard/fixtures/HWE-SKT/data.json @@ -1,46 +1,8 @@ { "wifi_ssid": "My Wi-Fi", "wifi_strength": 94, - "smr_version": null, - "meter_model": null, - "unique_meter_id": null, - "active_tariff": null, - "total_power_import_kwh": null, "total_power_import_t1_kwh": 63.651, - "total_power_import_t2_kwh": null, - "total_power_import_t3_kwh": null, - "total_power_import_t4_kwh": null, - "total_power_export_kwh": null, "total_power_export_t1_kwh": 0, - "total_power_export_t2_kwh": null, - "total_power_export_t3_kwh": null, - "total_power_export_t4_kwh": null, "active_power_w": 1457.277, - "active_power_l1_w": 1457.277, - "active_power_l2_w": null, - "active_power_l3_w": null, - "active_voltage_l1_v": null, - "active_voltage_l2_v": null, - "active_voltage_l3_v": null, - "active_current_l1_a": null, - "active_current_l2_a": null, - "active_current_l3_a": null, - "active_frequency_hz": null, - "voltage_sag_l1_count": null, - "voltage_sag_l2_count": null, - "voltage_sag_l3_count": null, - "voltage_swell_l1_count": null, - "voltage_swell_l2_count": null, - "voltage_swell_l3_count": null, - "any_power_fail_count": null, - "long_power_fail_count": null, - "active_power_average_w": null, - "monthly_power_peak_w": null, - "monthly_power_peak_timestamp": null, - "total_gas_m3": null, - "gas_timestamp": null, - "gas_unique_id": null, - "active_liter_lpm": null, - "total_liter_m3": null, - "external_devices": null + "active_power_l1_w": 1457.277 } diff --git a/tests/components/homewizard/fixtures/HWE-WTR/data.json b/tests/components/homewizard/fixtures/HWE-WTR/data.json index 169528abef4..16097742891 100644 --- a/tests/components/homewizard/fixtures/HWE-WTR/data.json +++ b/tests/components/homewizard/fixtures/HWE-WTR/data.json @@ -1,46 +1,6 @@ { "wifi_ssid": "My Wi-Fi", "wifi_strength": 84, - "smr_version": null, - "meter_model": null, - "unique_meter_id": null, - "active_tariff": null, - "total_power_import_kwh": null, - "total_power_import_t1_kwh": null, - "total_power_import_t2_kwh": null, - "total_power_import_t3_kwh": null, - "total_power_import_t4_kwh": null, - "total_power_export_kwh": null, - "total_power_export_t1_kwh": null, - "total_power_export_t2_kwh": null, - "total_power_export_t3_kwh": null, - "total_power_export_t4_kwh": null, - "active_power_w": null, - "active_power_l1_w": null, - "active_power_l2_w": null, - "active_power_l3_w": null, - "active_voltage_l1_v": null, - "active_voltage_l2_v": null, - "active_voltage_l3_v": null, - "active_current_l1_a": null, - "active_current_l2_a": null, - "active_current_l3_a": null, - "active_frequency_hz": null, - "voltage_sag_l1_count": null, - "voltage_sag_l2_count": null, - "voltage_sag_l3_count": null, - "voltage_swell_l1_count": null, - "voltage_swell_l2_count": null, - "voltage_swell_l3_count": null, - "any_power_fail_count": null, - "long_power_fail_count": null, - "active_power_average_w": null, - "monthly_power_peak_w": null, - "monthly_power_peak_timestamp": null, - "total_gas_m3": null, - "gas_timestamp": null, - "gas_unique_id": null, "active_liter_lpm": 0, - "total_liter_m3": 17.014, - "external_devices": null + "total_liter_m3": 17.014 } diff --git a/tests/components/homewizard/fixtures/SDM230/data.json b/tests/components/homewizard/fixtures/SDM230/data.json index e4eb045dff2..64fb2533359 100644 --- a/tests/components/homewizard/fixtures/SDM230/data.json +++ b/tests/components/homewizard/fixtures/SDM230/data.json @@ -1,46 +1,8 @@ { "wifi_ssid": "My Wi-Fi", "wifi_strength": 92, - "smr_version": null, - "meter_model": null, - "unique_meter_id": null, - "active_tariff": null, - "total_power_import_kwh": 2.705, "total_power_import_t1_kwh": 2.705, - "total_power_import_t2_kwh": null, - "total_power_import_t3_kwh": null, - "total_power_import_t4_kwh": null, - "total_power_export_kwh": 255.551, "total_power_export_t1_kwh": 255.551, - "total_power_export_t2_kwh": null, - "total_power_export_t3_kwh": null, - "total_power_export_t4_kwh": null, "active_power_w": -1058.296, - "active_power_l1_w": -1058.296, - "active_power_l2_w": null, - "active_power_l3_w": null, - "active_voltage_l1_v": null, - "active_voltage_l2_v": null, - "active_voltage_l3_v": null, - "active_current_l1_a": null, - "active_current_l2_a": null, - "active_current_l3_a": null, - "active_frequency_hz": null, - "voltage_sag_l1_count": null, - "voltage_sag_l2_count": null, - "voltage_sag_l3_count": null, - "voltage_swell_l1_count": null, - "voltage_swell_l2_count": null, - "voltage_swell_l3_count": null, - "any_power_fail_count": null, - "long_power_fail_count": null, - "active_power_average_w": null, - "monthly_power_peak_w": null, - "monthly_power_peak_timestamp": null, - "total_gas_m3": null, - "gas_timestamp": null, - "gas_unique_id": null, - "active_liter_lpm": null, - "total_liter_m3": null, - "external_devices": null + "active_power_l1_w": -1058.296 } diff --git a/tests/components/homewizard/fixtures/SDM630/data.json b/tests/components/homewizard/fixtures/SDM630/data.json index 593cf808efb..ee143220c67 100644 --- a/tests/components/homewizard/fixtures/SDM630/data.json +++ b/tests/components/homewizard/fixtures/SDM630/data.json @@ -1,46 +1,10 @@ { "wifi_ssid": "My Wi-Fi", "wifi_strength": 92, - "smr_version": null, - "meter_model": null, - "unique_meter_id": null, - "active_tariff": null, - "total_power_import_kwh": 0.101, "total_power_import_t1_kwh": 0.101, - "total_power_import_t2_kwh": null, - "total_power_import_t3_kwh": null, - "total_power_import_t4_kwh": null, - "total_power_export_kwh": 0.523, "total_power_export_t1_kwh": 0.523, - "total_power_export_t2_kwh": null, - "total_power_export_t3_kwh": null, - "total_power_export_t4_kwh": null, "active_power_w": -900.194, "active_power_l1_w": -1058.296, "active_power_l2_w": 158.102, - "active_power_l3_w": 0.0, - "active_voltage_l1_v": null, - "active_voltage_l2_v": null, - "active_voltage_l3_v": null, - "active_current_l1_a": null, - "active_current_l2_a": null, - "active_current_l3_a": null, - "active_frequency_hz": null, - "voltage_sag_l1_count": null, - "voltage_sag_l2_count": null, - "voltage_sag_l3_count": null, - "voltage_swell_l1_count": null, - "voltage_swell_l2_count": null, - "voltage_swell_l3_count": null, - "any_power_fail_count": null, - "long_power_fail_count": null, - "active_power_average_w": null, - "monthly_power_peak_w": null, - "monthly_power_peak_timestamp": null, - "total_gas_m3": null, - "gas_timestamp": null, - "gas_unique_id": null, - "active_liter_lpm": null, - "total_liter_m3": null, - "external_devices": null + "active_power_l3_w": 0.0 } diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 4fea0c3249e..01094ec2698 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -95,12 +95,12 @@ 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, 'smr_version': None, - 'total_energy_export_kwh': None, + 'total_energy_export_kwh': 0, 'total_energy_export_t1_kwh': 0, 'total_energy_export_t2_kwh': None, 'total_energy_export_t3_kwh': None, 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': None, + 'total_energy_import_kwh': 63.651, 'total_energy_import_t1_kwh': 63.651, 'total_energy_import_t2_kwh': None, 'total_energy_import_t3_kwh': None, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index d4004604f54..9e49c42ea98 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -6105,6 +6105,86 @@ 'state': '1457.277', }) # --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6185,6 +6265,86 @@ 'state': '0', }) # --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '63.651', + }) +# --- # name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 68616685eeb..006f12089ee 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -113,7 +113,9 @@ pytestmark = [ [ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", + "sensor.device_total_energy_import", "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_export", "sensor.device_total_energy_export_tariff_1", "sensor.device_active_power", "sensor.device_active_power_phase_1", @@ -285,11 +287,9 @@ async def test_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", - "sensor.device_total_energy_export", "sensor.device_total_energy_export_tariff_2", "sensor.device_total_energy_export_tariff_3", "sensor.device_total_energy_export_tariff_4", - "sensor.device_total_energy_import", "sensor.device_total_energy_import_tariff_2", "sensor.device_total_energy_import_tariff_3", "sensor.device_total_energy_import_tariff_4", From 4a5b1ab301d699668c81821db3761bdedbea39ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 26 Nov 2023 11:42:30 +0100 Subject: [PATCH 758/982] Migrate Epson to has entity name (#98164) --- .../components/epson/media_player.py | 39 ++++++++------- tests/components/epson/test_media_player.py | 49 +++++++++++++++++++ 2 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 tests/components/epson/test_media_player.py diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 1f80be9fe06..1f401ed0a7d 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -37,7 +37,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + DeviceInfo, + async_get as async_get_device_registry, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry @@ -55,8 +58,7 @@ async def async_setup_entry( projector: Projector = hass.data[DOMAIN][config_entry.entry_id] projector_entity = EpsonProjectorMediaPlayer( projector=projector, - name=config_entry.title, - unique_id=config_entry.unique_id, + unique_id=config_entry.unique_id or config_entry.entry_id, entry=config_entry, ) async_add_entities([projector_entity], True) @@ -71,6 +73,9 @@ async def async_setup_entry( class EpsonProjectorMediaPlayer(MediaPlayerEntity): """Representation of Epson Projector Device.""" + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF @@ -82,38 +87,38 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity): ) def __init__( - self, projector: Projector, name: str, unique_id: str | None, entry: ConfigEntry + self, projector: Projector, unique_id: str, entry: ConfigEntry ) -> None: """Initialize entity to control Epson projector.""" self._projector = projector self._entry = entry - self._attr_name = name self._attr_available = False self._cmode = None self._attr_source_list = list(DEFAULT_SOURCES.values()) self._attr_unique_id = unique_id - if unique_id: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer="Epson", - model="Epson", - name="Epson projector", - via_device=(DOMAIN, unique_id), - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Epson", + model="Epson", + ) async def set_unique_id(self) -> bool: """Set unique id for projector config entry.""" _LOGGER.debug("Setting unique_id for projector") - if self.unique_id: + if self._entry.unique_id: return False if uid := await self._projector.get_serial_number(): self.hass.config_entries.async_update_entry(self._entry, unique_id=uid) - registry = async_get_entity_registry(self.hass) - old_entity_id = registry.async_get_entity_id( + ent_reg = async_get_entity_registry(self.hass) + old_entity_id = ent_reg.async_get_entity_id( "media_player", DOMAIN, self._entry.entry_id ) if old_entity_id is not None: - registry.async_update_entity(old_entity_id, new_unique_id=uid) + ent_reg.async_update_entity(old_entity_id, new_unique_id=uid) + dev_reg = async_get_device_registry(self.hass) + device = dev_reg.async_get_device({(DOMAIN, self._entry.entry_id)}) + if device is not None: + dev_reg.async_update_device(device.id, new_identifiers={(DOMAIN, uid)}) self.hass.async_create_task( self.hass.config_entries.async_reload(self._entry.entry_id) ) diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py new file mode 100644 index 00000000000..d44036c680c --- /dev/null +++ b/tests/components/epson/test_media_player.py @@ -0,0 +1,49 @@ +"""Tests for the epson integration.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.epson.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_set_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +): + """Test the unique id is set on runtime.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Epson", + data={CONF_HOST: "1.1.1.1"}, + entry_id="1cb78c095906279574a0442a1f0003ef", + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.epson.Projector.get_power"): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.unique_id is None + entity = entity_registry.async_get("media_player.epson") + assert entity + assert entity.unique_id == entry.entry_id + with patch( + "homeassistant.components.epson.Projector.get_power", return_value="01" + ), patch( + "homeassistant.components.epson.Projector.get_serial_number", return_value="123" + ), patch( + "homeassistant.components.epson.Projector.get_property" + ): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + entity = entity_registry.async_get("media_player.epson") + assert entity + assert entity.unique_id == "123" + assert entry.unique_id == "123" From 32eab2c7ede343ee1e90fdee7c09af90d3fccfc9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 26 Nov 2023 11:42:47 +0100 Subject: [PATCH 759/982] Remove duplicate sensors on single phase HomeWizard meters (#104493) Co-authored-by: Duco Sebel <74970928+DCSBL@users.noreply.github.com> --- homeassistant/components/homewizard/sensor.py | 12 +- .../homewizard/snapshots/test_sensor.ambr | 480 ------------------ tests/components/homewizard/test_sensor.py | 12 +- 3 files changed, 16 insertions(+), 488 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 87235cdb6f2..78cee9ee6fe 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -114,7 +114,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t1_kwh is not None, + has_fn=lambda data: ( + # SKT/SDM230/630 provides both total and tariff 1: duplicate. + data.total_energy_import_t1_kwh is not None + and data.total_energy_export_t2_kwh is not None + ), value_fn=lambda data: data.total_energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( @@ -160,7 +164,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t1_kwh is not None, + has_fn=lambda data: ( + # SKT/SDM230/630 provides both total and tariff 1: duplicate. + data.total_energy_export_t1_kwh is not None + and data.total_energy_export_t2_kwh is not None + ), enabled_fn=lambda data: data.total_energy_export_t1_kwh != 0, value_fn=lambda data: data.total_energy_export_t1_kwh, ), diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 9e49c42ea98..e237edee58e 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -6185,86 +6185,6 @@ 'state': '0', }) # --- -# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-SKT', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.03', - 'via_device_id': None, - }) -# --- -# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_export_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy export tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_export_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy export tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_export_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '0', - }) -# --- # name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6345,86 +6265,6 @@ 'state': '63.651', }) # --- -# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'HWE-SKT', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.03', - 'via_device_id': None, - }) -# --- -# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_import_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy import tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy import tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_import_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '63.651', - }) -# --- # name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7144,86 +6984,6 @@ 'state': '255.551', }) # --- -# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_export_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy export tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_export_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy export tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_export_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '255.551', - }) -# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7304,86 +7064,6 @@ 'state': '2.705', }) # --- -# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_import_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy import tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy import tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_import_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '2.705', - }) -# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7952,86 +7632,6 @@ 'state': '0.523', }) # --- -# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_export_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy export tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_export_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy export tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_export_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '0.523', - }) -# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -8112,86 +7712,6 @@ 'state': '0.101', }) # --- -# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import_tariff_1:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '3c:39:e7:aa:bb:cc', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '3c39e7aabbcc', - ), - }), - 'is_new': False, - 'manufacturer': 'HomeWizard', - 'model': 'SDM630-wifi', - 'name': 'Device', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '3.06', - 'via_device_id': None, - }) -# --- -# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import_tariff_1:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_import_tariff_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total energy import tariff 1', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import_tariff_1:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy import tariff 1', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.device_total_energy_import_tariff_1', - 'last_changed': , - 'last_updated': , - 'state': '0.101', - }) -# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 006f12089ee..7e59769a768 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -114,9 +114,7 @@ pytestmark = [ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", "sensor.device_active_power", "sensor.device_active_power_phase_1", ], @@ -136,9 +134,7 @@ pytestmark = [ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", "sensor.device_active_power", "sensor.device_active_power_phase_1", ], @@ -149,9 +145,7 @@ pytestmark = [ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", "sensor.device_active_power", "sensor.device_active_power_phase_1", "sensor.device_active_power_phase_2", @@ -287,9 +281,11 @@ async def test_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_total_energy_export_tariff_1", "sensor.device_total_energy_export_tariff_2", "sensor.device_total_energy_export_tariff_3", "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import_tariff_1", "sensor.device_total_energy_import_tariff_2", "sensor.device_total_energy_import_tariff_3", "sensor.device_total_energy_import_tariff_4", @@ -367,9 +363,11 @@ async def test_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_total_energy_export_tariff_1", "sensor.device_total_energy_export_tariff_2", "sensor.device_total_energy_export_tariff_3", "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import_tariff_1", "sensor.device_total_energy_import_tariff_2", "sensor.device_total_energy_import_tariff_3", "sensor.device_total_energy_import_tariff_4", @@ -403,9 +401,11 @@ async def test_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_total_energy_export_tariff_1", "sensor.device_total_energy_export_tariff_2", "sensor.device_total_energy_export_tariff_3", "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import_tariff_1", "sensor.device_total_energy_import_tariff_2", "sensor.device_total_energy_import_tariff_3", "sensor.device_total_energy_import_tariff_4", From e3599bc26f155fe079d74fa11e93d37e0d406d5a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 26 Nov 2023 13:04:52 +0100 Subject: [PATCH 760/982] Move APCUPSd coordinator to separate file (#104540) --- homeassistant/components/apcupsd/__init__.py | 95 +--------------- homeassistant/components/apcupsd/const.py | 4 + .../components/apcupsd/coordinator.py | 102 ++++++++++++++++++ tests/components/apcupsd/test_init.py | 3 +- tests/components/apcupsd/test_sensor.py | 2 +- 5 files changed, 112 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/apcupsd/const.py create mode 100644 homeassistant/components/apcupsd/coordinator.py diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 8431d282e7d..550e1014d2a 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -1,32 +1,20 @@ """Support for APCUPSd via its Network Information Server (NIS).""" from __future__ import annotations -import asyncio -from collections import OrderedDict -from datetime import timedelta import logging from typing import Final -from apcaccess import status - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - REQUEST_REFRESH_DEFAULT_IMMEDIATE, - DataUpdateCoordinator, - UpdateFailed, -) + +from .const import DOMAIN +from .coordinator import APCUPSdCoordinator _LOGGER = logging.getLogger(__name__) -DOMAIN: Final = "apcupsd" PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR) -UPDATE_INTERVAL: Final = timedelta(seconds=60) -REQUEST_REFRESH_COOLDOWN: Final = 5 CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -53,80 +41,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok and DOMAIN in hass.data: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): - """Store and coordinate the data retrieved from APCUPSd for all sensors. - - For each entity to use, acts as the single point responsible for fetching - updates from the server. - """ - - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: - """Initialize the data object.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=UPDATE_INTERVAL, - request_refresh_debouncer=Debouncer( - hass, - _LOGGER, - cooldown=REQUEST_REFRESH_COOLDOWN, - immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, - ), - ) - self._host = host - self._port = port - - @property - def ups_name(self) -> str | None: - """Return the name of the UPS, if available.""" - return self.data.get("UPSNAME") - - @property - def ups_model(self) -> str | None: - """Return the model of the UPS, if available.""" - # Different UPS models may report slightly different keys for model, here we - # try them all. - for model_key in ("APCMODEL", "MODEL"): - if model_key in self.data: - return self.data[model_key] - return None - - @property - def ups_serial_no(self) -> str | None: - """Return the unique serial number of the UPS, if available.""" - return self.data.get("SERIALNO") - - @property - def device_info(self) -> DeviceInfo | None: - """Return the DeviceInfo of this APC UPS, if serial number is available.""" - if not self.ups_serial_no: - return None - - return DeviceInfo( - identifiers={(DOMAIN, self.ups_serial_no)}, - model=self.ups_model, - manufacturer="APC", - name=self.ups_name if self.ups_name else "APC UPS", - hw_version=self.data.get("FIRMWARE"), - sw_version=self.data.get("VERSION"), - ) - - async def _async_update_data(self) -> OrderedDict[str, str]: - """Fetch the latest status from APCUPSd. - - Note that the result dict uses upper case for each resource, where our - integration uses lower cases as keys internally. - """ - - async with asyncio.timeout(10): - try: - raw = await self.hass.async_add_executor_job( - status.get, self._host, self._port - ) - result: OrderedDict[str, str] = status.parse(raw) - return result - except OSError as error: - raise UpdateFailed(error) from error diff --git a/homeassistant/components/apcupsd/const.py b/homeassistant/components/apcupsd/const.py new file mode 100644 index 00000000000..cacc9e29369 --- /dev/null +++ b/homeassistant/components/apcupsd/const.py @@ -0,0 +1,4 @@ +"""Constants for APCUPSd component.""" +from typing import Final + +DOMAIN: Final = "apcupsd" diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py new file mode 100644 index 00000000000..321da56095a --- /dev/null +++ b/homeassistant/components/apcupsd/coordinator.py @@ -0,0 +1,102 @@ +"""Support for APCUPSd via its Network Information Server (NIS).""" +from __future__ import annotations + +import asyncio +from collections import OrderedDict +from datetime import timedelta +import logging +from typing import Final + +from apcaccess import status + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + REQUEST_REFRESH_DEFAULT_IMMEDIATE, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL: Final = timedelta(seconds=60) +REQUEST_REFRESH_COOLDOWN: Final = 5 + + +class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): + """Store and coordinate the data retrieved from APCUPSd for all sensors. + + For each entity to use, acts as the single point responsible for fetching + updates from the server. + """ + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize the data object.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, + _LOGGER, + cooldown=REQUEST_REFRESH_COOLDOWN, + immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, + ), + ) + self._host = host + self._port = port + + @property + def ups_name(self) -> str | None: + """Return the name of the UPS, if available.""" + return self.data.get("UPSNAME") + + @property + def ups_model(self) -> str | None: + """Return the model of the UPS, if available.""" + # Different UPS models may report slightly different keys for model, here we + # try them all. + for model_key in ("APCMODEL", "MODEL"): + if model_key in self.data: + return self.data[model_key] + return None + + @property + def ups_serial_no(self) -> str | None: + """Return the unique serial number of the UPS, if available.""" + return self.data.get("SERIALNO") + + @property + def device_info(self) -> DeviceInfo | None: + """Return the DeviceInfo of this APC UPS, if serial number is available.""" + if not self.ups_serial_no: + return None + + return DeviceInfo( + identifiers={(DOMAIN, self.ups_serial_no)}, + model=self.ups_model, + manufacturer="APC", + name=self.ups_name if self.ups_name else "APC UPS", + hw_version=self.data.get("FIRMWARE"), + sw_version=self.data.get("VERSION"), + ) + + async def _async_update_data(self) -> OrderedDict[str, str]: + """Fetch the latest status from APCUPSd. + + Note that the result dict uses upper case for each resource, where our + integration uses lower cases as keys internally. + """ + + async with asyncio.timeout(10): + try: + raw = await self.hass.async_add_executor_job( + status.get, self._host, self._port + ) + result: OrderedDict[str, str] = status.parse(raw) + return result + except OSError as error: + raise UpdateFailed(error) from error diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 43eab28eb46..756fa07f120 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -4,7 +4,8 @@ from unittest.mock import patch import pytest -from homeassistant.components.apcupsd import DOMAIN, UPDATE_INTERVAL +from homeassistant.components.apcupsd.const import DOMAIN +from homeassistant.components.apcupsd.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 73546613002..bff1b858216 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.components.apcupsd import REQUEST_REFRESH_COOLDOWN +from homeassistant.components.apcupsd.coordinator import REQUEST_REFRESH_COOLDOWN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, From 2e1c722303ec54b64d2f247ac2218b76fdf4b67c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 26 Nov 2023 13:07:21 +0100 Subject: [PATCH 761/982] Add entity translations to Balboa (#104543) --- .../components/balboa/binary_sensor.py | 18 +++++++----------- homeassistant/components/balboa/climate.py | 1 + homeassistant/components/balboa/entity.py | 5 ++--- homeassistant/components/balboa/strings.json | 11 +++++++++++ tests/components/balboa/test_binary_sensor.py | 4 ++-- tests/components/balboa/test_climate.py | 2 +- 6 files changed, 24 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 9f363746a8f..7462d051643 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -47,31 +47,27 @@ class BalboaBinarySensorEntityDescription( ): """A class that describes Balboa binary sensor entities.""" - # BalboaBinarySensorEntity does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - FILTER_CYCLE_ICONS = ("mdi:sync", "mdi:sync-off") BINARY_SENSOR_DESCRIPTIONS = ( BalboaBinarySensorEntityDescription( - key="filter_cycle_1", - name="Filter1", + key="Filter1", + translation_key="filter_1", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: spa.filter_cycle_1_running, on_off_icons=FILTER_CYCLE_ICONS, ), BalboaBinarySensorEntityDescription( - key="filter_cycle_2", - name="Filter2", + key="Filter2", + translation_key="filter_2", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: spa.filter_cycle_2_running, on_off_icons=FILTER_CYCLE_ICONS, ), ) CIRCULATION_PUMP_DESCRIPTION = BalboaBinarySensorEntityDescription( - key="circulation_pump", - name="Circ Pump", + key="Circ Pump", + translation_key="circ_pump", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: (pump := spa.circulation_pump) is not None and pump.state > 0, on_off_icons=("mdi:pump", "mdi:pump-off"), @@ -87,7 +83,7 @@ class BalboaBinarySensorEntity(BalboaEntity, BinarySensorEntity): self, spa: SpaClient, description: BalboaBinarySensorEntityDescription ) -> None: """Initialize a Balboa binary sensor entity.""" - super().__init__(spa, description.name) + super().__init__(spa, description.key) self.entity_description = description @property diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index 0d0fa9bd179..d213a8fd2e8 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -59,6 +59,7 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_translation_key = DOMAIN + _attr_name = None def __init__(self, client: SpaClient) -> None: """Initialize the climate entity.""" diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index 3b4f7d08fff..e02579658da 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -15,12 +15,11 @@ class BalboaEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, client: SpaClient, name: str | None = None) -> None: + def __init__(self, client: SpaClient, key: str) -> None: """Initialize the control.""" mac = client.mac_address model = client.model - self._attr_unique_id = f'{model}-{name}-{mac.replace(":","")[-6:]}' - self._attr_name = name + self._attr_unique_id = f'{model}-{key}-{mac.replace(":","")[-6:]}' self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, name=model, diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 214ccf8fbe1..238deb7d65d 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -26,6 +26,17 @@ } }, "entity": { + "binary_sensor": { + "filter_1": { + "name": "Filter cycle 1" + }, + "filter_2": { + "name": "Filter cycle 2" + }, + "circ_pump": { + "name": "Circulation pump" + } + }, "climate": { "balboa": { "state_attributes": { diff --git a/tests/components/balboa/test_binary_sensor.py b/tests/components/balboa/test_binary_sensor.py index e97887b154a..ee5f2bc353c 100644 --- a/tests/components/balboa/test_binary_sensor.py +++ b/tests/components/balboa/test_binary_sensor.py @@ -16,7 +16,7 @@ async def test_filters( ) -> None: """Test spa filters.""" for num in (1, 2): - sensor = f"{ENTITY_BINARY_SENSOR}filter{num}" + sensor = f"{ENTITY_BINARY_SENSOR}filter_cycle_{num}" state = hass.states.get(sensor) assert state.state == STATE_OFF @@ -33,7 +33,7 @@ async def test_circ_pump( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry ) -> None: """Test spa circ pump.""" - sensor = f"{ENTITY_BINARY_SENSOR}circ_pump" + sensor = f"{ENTITY_BINARY_SENSOR}circulation_pump" state = hass.states.get(sensor) assert state.state == STATE_OFF diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 4967bcdfa38..90ef6c75e5f 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -38,7 +38,7 @@ HVAC_SETTINGS = [ HVACMode.AUTO, ] -ENTITY_CLIMATE = "climate.fakespa_climate" +ENTITY_CLIMATE = "climate.fakespa" async def test_spa_defaults( From 959b98be0e7162e20ce94a7cf6d6a7406eb5881d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 26 Nov 2023 13:08:10 +0100 Subject: [PATCH 762/982] Plugwise: bug-fix for Anna + Techneco Elga combination (#104521) --- .../components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../anna_heatpump_heating/all_data.json | 12 +++++++---- .../m_anna_heatpump_cooling/all_data.json | 5 +++-- .../m_anna_heatpump_idle/all_data.json | 5 +++-- tests/components/plugwise/test_climate.py | 21 ++++++++++--------- 7 files changed, 28 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 74b196b6edd..1373ba40fa3 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.34.0"], + "requirements": ["plugwise==0.34.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7dee603a176..71891598957 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1477,7 +1477,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.0 +plugwise==0.34.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b56d662f9aa..fdf0754ebce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1135,7 +1135,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.0 +plugwise==0.34.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index e7e13e17357..9ef93d63bdd 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -21,6 +21,7 @@ "binary_sensors": { "compressor_state": true, "cooling_enabled": false, + "cooling_state": false, "dhw_state": false, "flame_state": false, "heating_state": true, @@ -40,7 +41,7 @@ "setpoint": 60.0, "upper_bound": 100.0 }, - "model": "Generic heater", + "model": "Generic heater/cooler", "name": "OpenTherm", "sensors": { "dhw_temperature": 46.3, @@ -72,7 +73,8 @@ "cooling_activation_outdoor_temperature": 21.0, "cooling_deactivation_threshold": 4.0, "illuminance": 86.0, - "setpoint": 20.5, + "setpoint_high": 30.0, + "setpoint_low": 20.5, "temperature": 19.3 }, "temperature_offset": { @@ -84,16 +86,18 @@ "thermostat": { "lower_bound": 4.0, "resolution": 0.1, - "setpoint": 20.5, + "setpoint_high": 30.0, + "setpoint_low": 20.5, "upper_bound": 30.0 }, "vendor": "Plugwise" } }, "gateway": { - "cooling_present": false, + "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "item_count": 66, "notifications": {}, "smile_name": "Smile Anna" } diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index 40364e620c3..844eae4c2f7 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -73,7 +73,7 @@ "cooling_activation_outdoor_temperature": 21.0, "cooling_deactivation_threshold": 4.0, "illuminance": 86.0, - "setpoint_high": 24.0, + "setpoint_high": 30.0, "setpoint_low": 20.5, "temperature": 26.3 }, @@ -86,7 +86,7 @@ "thermostat": { "lower_bound": 4.0, "resolution": 0.1, - "setpoint_high": 24.0, + "setpoint_high": 30.0, "setpoint_low": 20.5, "upper_bound": 30.0 }, @@ -97,6 +97,7 @@ "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "item_count": 66, "notifications": {}, "smile_name": "Smile Anna" } diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 3a84a59deea..f6be6f35188 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -73,7 +73,7 @@ "cooling_activation_outdoor_temperature": 25.0, "cooling_deactivation_threshold": 4.0, "illuminance": 86.0, - "setpoint_high": 24.0, + "setpoint_high": 30.0, "setpoint_low": 20.5, "temperature": 23.0 }, @@ -86,7 +86,7 @@ "thermostat": { "lower_bound": 4.0, "resolution": 0.1, - "setpoint_high": 24.0, + "setpoint_high": 30.0, "setpoint_low": 20.5, "upper_bound": 30.0 }, @@ -97,6 +97,7 @@ "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "item_count": 66, "notifications": {}, "smile_name": "Smile Anna" } diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 8b4c4d5a745..c14fd802e3b 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -296,17 +296,18 @@ async def test_anna_climate_entity_attributes( assert state assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] + assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT_COOL] assert "no_frost" in state.attributes["preset_modes"] assert "home" in state.attributes["preset_modes"] assert state.attributes["current_temperature"] == 19.3 assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 17 - assert state.attributes["temperature"] == 20.5 - assert state.attributes["min_temp"] == 4.0 - assert state.attributes["max_temp"] == 30.0 + assert state.attributes["supported_features"] == 18 + assert state.attributes["target_temp_high"] == 30 + assert state.attributes["target_temp_low"] == 20.5 + assert state.attributes["min_temp"] == 4 + assert state.attributes["max_temp"] == 30 assert state.attributes["target_temp_step"] == 0.1 @@ -325,7 +326,7 @@ async def test_anna_2_climate_entity_attributes( HVACMode.HEAT_COOL, ] assert state.attributes["supported_features"] == 18 - assert state.attributes["target_temp_high"] == 24.0 + assert state.attributes["target_temp_high"] == 30 assert state.attributes["target_temp_low"] == 20.5 @@ -354,13 +355,13 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_temperature", - {"entity_id": "climate.anna", "target_temp_high": 25, "target_temp_low": 20}, + {"entity_id": "climate.anna", "target_temp_high": 30, "target_temp_low": 20}, blocking=True, ) assert mock_smile_anna.set_temperature.call_count == 1 mock_smile_anna.set_temperature.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", - {"setpoint_high": 25.0, "setpoint_low": 20.0}, + {"setpoint_high": 30.0, "setpoint_low": 20.0}, ) await hass.services.async_call( @@ -386,7 +387,7 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "heat"}, + {"entity_id": "climate.anna", "hvac_mode": "heat_cool"}, blocking=True, ) assert mock_smile_anna.set_schedule_state.call_count == 1 @@ -400,4 +401,4 @@ async def test_anna_climate_entity_climate_changes( await hass.async_block_till_done() state = hass.states.get("climate.anna") assert state.state == HVACMode.HEAT - assert state.attributes["hvac_modes"] == [HVACMode.HEAT] + assert state.attributes["hvac_modes"] == [HVACMode.HEAT_COOL] From c831802774dbeb237918f5806561cdb0e3ffcb75 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 26 Nov 2023 13:14:01 +0100 Subject: [PATCH 763/982] Bump `nextdns` to version 2.1.0 (#104545) --- homeassistant/components/nextdns/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 725ce1b9201..611021d73e4 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==2.0.1"] + "requirements": ["nextdns==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 71891598957..d1721786f01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1313,7 +1313,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==2.0.1 +nextdns==2.1.0 # homeassistant.components.nibe_heatpump nibe==2.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdf0754ebce..5d4d29c74fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1028,7 +1028,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==2.0.1 +nextdns==2.1.0 # homeassistant.components.nibe_heatpump nibe==2.5.2 From 14387cf94b65b686b4cbe5f6d11970e45619172d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 26 Nov 2023 14:46:29 +0100 Subject: [PATCH 764/982] Remove Shelly Wall Display switch entity only if the relay is used as the thermostat actuator (#104506) --- homeassistant/components/shelly/climate.py | 13 ++++++++++--- homeassistant/components/shelly/utils.py | 7 +++++++ tests/components/shelly/conftest.py | 7 ++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index dbc4960af58..d855e8b238b 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -42,7 +42,12 @@ from .const import ( ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyRpcEntity -from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_key_ids, + is_relay_used_as_actuator, +) async def async_setup_entry( @@ -125,8 +130,10 @@ def async_setup_rpc_entry( climate_ids = [] for id_ in climate_key_ids: climate_ids.append(id_) - unique_id = f"{coordinator.mac}-switch:{id_}" - async_remove_shelly_entity(hass, "switch", unique_id) + + if is_relay_used_as_actuator(id_, coordinator.mac, coordinator.device.config): + unique_id = f"{coordinator.mac}-switch:{id_}" + async_remove_shelly_entity(hass, "switch", unique_id) if not climate_ids: return diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 6b5c59f28db..0209dc63aa8 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -430,3 +430,10 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL + + +def is_relay_used_as_actuator(relay_id: int, mac: str, config: dict[str, Any]) -> bool: + """Return True if an internal relay is used as the thermostat actuator.""" + return f"{mac}/c/switch:{relay_id}".lower() in config[f"thermostat:{relay_id}"].get( + "actuator", "" + ) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index aeeaf9242a1..1662405dc80 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -149,7 +149,12 @@ MOCK_CONFIG = { "light:0": {"name": "test light_0"}, "switch:0": {"name": "test switch_0"}, "cover:0": {"name": "test cover_0"}, - "thermostat:0": {"id": 0, "enable": True, "type": "heating"}, + "thermostat:0": { + "id": 0, + "enable": True, + "type": "heating", + "actuator": f"shelly://shellywalldisplay-{MOCK_MAC.lower()}/c/switch:0", + }, "sys": { "ui_data": {}, "device": {"name": "Test name"}, From ad17acc6ca4e597bdf9d89076408a7caa07cdcb2 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sun, 26 Nov 2023 15:28:58 +0100 Subject: [PATCH 765/982] Fix async issue in ViCare integration (#104541) * use async executor for get_circuits * use async executor for get_burners and get_compressors --- homeassistant/components/vicare/binary_sensor.py | 9 ++++++--- homeassistant/components/vicare/climate.py | 12 ++++++++---- homeassistant/components/vicare/number.py | 5 +++-- homeassistant/components/vicare/sensor.py | 9 ++++++--- homeassistant/components/vicare/water_heater.py | 2 +- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 3b49fcb9134..bab132121f6 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -159,16 +159,19 @@ async def async_setup_entry( if entity is not None: entities.append(entity) + circuits = await hass.async_add_executor_job(get_circuits, api) await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, get_circuits(api), config_entry + hass, entities, CIRCUIT_SENSORS, circuits, config_entry ) + burners = await hass.async_add_executor_job(get_burners, api) await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, get_burners(api), config_entry + hass, entities, BURNER_SENSORS, burners, config_entry ) + compressors = await hass.async_add_executor_job(get_compressors, api) await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, get_compressors(api), config_entry + hass, entities, COMPRESSOR_SENSORS, compressors, config_entry ) async_add_entities(entities) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 97df501f80b..b32b6e28480 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -103,7 +103,7 @@ async def async_setup_entry( entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = get_circuits(api) + circuits = await hass.async_add_executor_job(get_circuits, api) for circuit in circuits: entity = ViCareClimate( @@ -154,7 +154,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._current_program = None self._attr_translation_key = translation_key - def update(self) -> None: + async def async_update(self) -> None: """Let HA know there has been an update from the ViCare API.""" try: _room_temperature = None @@ -205,11 +205,15 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._current_action = False # Update the specific device attributes with suppress(PyViCareNotSupportedFeatureError): - for burner in get_burners(self._api): + burners = await self.hass.async_add_executor_job(get_burners, self._api) + for burner in burners: self._current_action = self._current_action or burner.getActive() with suppress(PyViCareNotSupportedFeatureError): - for compressor in get_compressors(self._api): + compressors = await self.hass.async_add_executor_job( + get_compressors, self._api + ) + for compressor in compressors: self._current_action = ( self._current_action or compressor.getActive() ) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index cfbe13e1267..577bb8257ea 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity -from .utils import is_supported +from .utils import get_circuits, is_supported _LOGGER = logging.getLogger(__name__) @@ -93,10 +93,11 @@ async def async_setup_entry( ) -> None: """Create the ViCare number devices.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + circuits = await hass.async_add_executor_job(get_circuits, api) entities: list[ViCareNumber] = [] try: - for circuit in api.circuits: + for circuit in circuits: for description in CIRCUIT_ENTITY_DESCRIPTIONS: entity = await hass.async_add_executor_job( _build_entity, diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index a3f05cfc84b..caf1151f5ec 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -650,16 +650,19 @@ async def async_setup_entry( if entity is not None: entities.append(entity) + circuits = await hass.async_add_executor_job(get_circuits, api) await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, get_circuits(api), config_entry + hass, entities, CIRCUIT_SENSORS, circuits, config_entry ) + burners = await hass.async_add_executor_job(get_burners, api) await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, get_burners(api), config_entry + hass, entities, BURNER_SENSORS, burners, config_entry ) + compressors = await hass.async_add_executor_job(get_compressors, api) await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, get_compressors(api), config_entry + hass, entities, COMPRESSOR_SENSORS, compressors, config_entry ) async_add_entities(entities) diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 9b33ca9947a..9b154da2bc2 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -67,7 +67,7 @@ async def async_setup_entry( entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = get_circuits(api) + circuits = await hass.async_add_executor_job(get_circuits, api) for circuit in circuits: entity = ViCareWater( From b314df272f4c8b5da45df7f65a956898c81bae7c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 26 Nov 2023 17:32:47 +0100 Subject: [PATCH 766/982] Cleanup Discovergy a bit (#104552) Co-authored-by: Joost Lekkerkerker --- .../components/discovergy/__init__.py | 6 +- .../components/discovergy/config_flow.py | 4 +- .../components/discovergy/coordinator.py | 7 +- .../components/discovergy/diagnostics.py | 5 +- homeassistant/components/discovergy/sensor.py | 5 +- tests/components/discovergy/conftest.py | 15 ++-- .../components/discovergy/test_config_flow.py | 69 ++++++++----------- 7 files changed, 45 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 32f696a04ce..f21a03ef748 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -import pydiscovergy +from pydiscovergy import Discovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError from pydiscovergy.models import Meter @@ -24,7 +24,7 @@ PLATFORMS = [Platform.SENSOR] class DiscovergyData: """Discovergy data class to share meters and api client.""" - api_client: pydiscovergy.Discovergy + api_client: Discovergy meters: list[Meter] coordinators: dict[str, DiscovergyUpdateCoordinator] @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # init discovergy data class discovergy_data = DiscovergyData( - api_client=pydiscovergy.Discovergy( + api_client=Discovergy( email=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], httpx_client=get_async_client(hass), diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index b3dee2d82a0..38a250a381d 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -5,7 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -import pydiscovergy +from pydiscovergy import Discovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError import voluptuous as vol @@ -70,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: try: - await pydiscovergy.Discovergy( + await Discovergy( email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD], httpx_client=get_async_client(self.hass), diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 5f27c6a43d2..5a3448a9e4b 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -12,17 +12,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN - _LOGGER = logging.getLogger(__name__) class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): """The Discovergy update coordinator.""" - discovergy_client: Discovergy - meter: Meter - def __init__( self, hass: HomeAssistant, @@ -36,7 +31,7 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): super().__init__( hass, _LOGGER, - name=DOMAIN, + name=f"Discovergy meter {meter.meter_id}", update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index e0a9e47e6fd..75c6f97c701 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -4,8 +4,6 @@ from __future__ import annotations from dataclasses import asdict from typing import Any -from pydiscovergy.models import Meter - from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -30,9 +28,8 @@ async def async_get_config_entry_diagnostics( flattened_meter: list[dict] = [] last_readings: dict[str, dict] = {} data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] - meters: list[Meter] = data.meters # always returns a list - for meter in meters: + for meter in data.meters: # make a dict of meter data and redact some data flattened_meter.append(async_redact_data(asdict(meter), TO_REDACT_METER)) diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 508af900a1c..ed878fbb82e 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -27,8 +27,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DiscovergyData, DiscovergyUpdateCoordinator from .const import DOMAIN, MANUFACTURER -PARALLEL_UPDATES = 1 - def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None: """Get a value from a Reading and divide with scale it.""" @@ -168,10 +166,9 @@ async def async_setup_entry( ) -> None: """Set up the Discovergy sensors.""" data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] - meters: list[Meter] = data.meters # always returns a list entities: list[DiscovergySensor] = [] - for meter in meters: + for meter in data.meters: sensors: tuple[DiscovergySensorEntityDescription, ...] = () coordinator: DiscovergyUpdateCoordinator = data.coordinators[meter.meter_id] diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py index 2409c30bc6c..819a1cbb72a 100644 --- a/tests/components/discovergy/conftest.py +++ b/tests/components/discovergy/conftest.py @@ -2,7 +2,6 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from pydiscovergy import Discovergy from pydiscovergy.models import Reading import pytest @@ -27,14 +26,16 @@ def _meter_last_reading(meter_id: str) -> Reading: @pytest.fixture(name="discovergy") def mock_discovergy() -> Generator[AsyncMock, None, None]: """Mock the pydiscovergy client.""" - mock = AsyncMock(spec=Discovergy) - mock.meters.return_value = GET_METERS - mock.meter_last_reading.side_effect = _meter_last_reading - with patch( - "homeassistant.components.discovergy.pydiscovergy.Discovergy", - return_value=mock, + "homeassistant.components.discovergy.Discovergy", + autospec=True, + ) as mock_discovergy, patch( + "homeassistant.components.discovergy.config_flow.Discovergy", + new=mock_discovergy, ): + mock = mock_discovergy.return_value + mock.meters.return_value = GET_METERS + mock.meter_last_reading.side_effect = _meter_last_reading yield mock diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 16ba3a1546e..7c257f814c4 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -11,7 +11,6 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -from tests.components.discovergy.const import GET_METERS async def test_form(hass: HomeAssistant, discovergy: AsyncMock) -> None: @@ -25,10 +24,7 @@ async def test_form(hass: HomeAssistant, discovergy: AsyncMock) -> None: with patch( "homeassistant.components.discovergy.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.discovergy.config_flow.pydiscovergy.Discovergy", - return_value=discovergy, - ): + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -65,10 +61,7 @@ async def test_reauth( with patch( "homeassistant.components.discovergy.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.discovergy.config_flow.pydiscovergy.Discovergy", - return_value=discovergy, - ): + ) as mock_setup_entry: configure_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], { @@ -92,38 +85,34 @@ async def test_reauth( (Exception, "unknown"), ], ) -async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> None: +async def test_form_fail( + hass: HomeAssistant, discovergy: AsyncMock, error: Exception, message: str +) -> None: """Test to handle exceptions.""" + discovergy.meters.side_effect = error + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) - with patch( - "homeassistant.components.discovergy.config_flow.pydiscovergy.Discovergy.meters", - side_effect=error, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": message} + # reset and test for success + discovergy.meters.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) - with patch( - "homeassistant.components.discovergy.config_flow.pydiscovergy.Discovergy.meters", - return_value=GET_METERS, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "test@example.com" - assert "errors" not in result + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert "errors" not in result From 8a1f7b68024caddc96659fe812b99671683755b0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 26 Nov 2023 17:33:54 +0100 Subject: [PATCH 767/982] Add translation key for some mqtt exceptions (#104550) --- homeassistant/components/mqtt/__init__.py | 6 +++--- homeassistant/components/mqtt/client.py | 23 +++++++++++++++------- homeassistant/components/mqtt/mixins.py | 4 ++-- homeassistant/components/mqtt/models.py | 8 ++++---- homeassistant/components/mqtt/strings.json | 8 +++++++- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index dd51b276715..16f584db011 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -245,11 +245,11 @@ async def async_check_config_schema( for config in config_items: try: schema(config) - except vol.Invalid as ex: + except vol.Invalid as exc: integration = await async_get_integration(hass, DOMAIN) # pylint: disable-next=protected-access message = conf_util.format_schema_error( - hass, ex, domain, config, integration.documentation + hass, exc, domain, config, integration.documentation ) raise ServiceValidationError( message, @@ -258,7 +258,7 @@ async def async_check_config_schema( translation_placeholders={ "domain": domain, }, - ) from ex + ) from exc async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 2e4d49b4cd9..c87d4c9244a 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -124,7 +124,10 @@ async def async_publish( """Publish message to a MQTT topic.""" if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( - f"Cannot publish to topic '{topic}', MQTT is not enabled" + f"Cannot publish to topic '{topic}', MQTT is not enabled", + translation_key="mqtt_not_setup_cannot_publish", + translation_domain=DOMAIN, + translation_placeholders={"topic": topic}, ) mqtt_data = get_mqtt_data(hass) outgoing_payload = payload @@ -174,15 +177,21 @@ async def async_subscribe( """ if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', MQTT is not enabled" + f"Cannot subscribe to topic '{topic}', MQTT is not enabled", + translation_key="mqtt_not_setup_cannot_subscribe", + translation_domain=DOMAIN, + translation_placeholders={"topic": topic}, ) try: mqtt_data = get_mqtt_data(hass) - except KeyError as ex: + except KeyError as exc: raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', " - "make sure MQTT is set up correctly" - ) from ex + "make sure MQTT is set up correctly", + translation_key="mqtt_not_setup_cannot_subscribe", + translation_domain=DOMAIN, + translation_placeholders={"topic": topic}, + ) from exc async_remove = await mqtt_data.client.async_subscribe( topic, catch_log_exception( @@ -606,8 +615,8 @@ class MQTT: del simple_subscriptions[topic] else: self._wildcard_subscriptions.remove(subscription) - except (KeyError, ValueError) as ex: - raise HomeAssistantError("Can't remove subscription twice") from ex + except (KeyError, ValueError) as exc: + raise HomeAssistantError("Can't remove subscription twice") from exc @callback def _async_queue_subscriptions( diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index d84f430bd85..76300afb97a 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -457,8 +457,8 @@ async def async_setup_entity_entry_helper( if TYPE_CHECKING: assert entity_class is not None entities.append(entity_class(hass, config, entry, None)) - except vol.Invalid as ex: - error = str(ex) + except vol.Invalid as exc: + error = str(exc) config_file = getattr(yaml_config, "__config_file__", "?") line = getattr(yaml_config, "__line__", "?") issue_id = hex(hash(frozenset(yaml_config))) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 2da2527ad7b..63b8d537170 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -247,15 +247,15 @@ class MqttValueTemplate: payload, variables=values ) ) - except Exception as ex: + except Exception as exc: _LOGGER.error( "%s: %s rendering template for entity '%s', template: '%s'", - type(ex).__name__, - ex, + type(exc).__name__, + exc, self._entity.entity_id if self._entity else "n/a", self._value_template.template, ) - raise ex + raise exc return rendered_payload _LOGGER.debug( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 7f8dcfedd9a..f35cd7c2b58 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -214,7 +214,13 @@ }, "exceptions": { "invalid_platform_config": { - "message": "Reloading YAML config for manually configured MQTT `{domain}` failed. See logs for more details." + "message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. See logs for more details." + }, + "mqtt_not_setup_cannot_subscribe": { + "message": "Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly." + }, + "mqtt_not_setup_cannot_publish": { + "message": "Cannot publish to topic '{topic}', make sure MQTT is set up correctly." } } } From 6e5dfa0e9b330a9952792562462b0e637bc1f528 Mon Sep 17 00:00:00 2001 From: On Freund Date: Sun, 26 Nov 2023 18:38:47 +0200 Subject: [PATCH 768/982] Add OurGroceries integration (#103387) * Add OurGroceries integration * Handle review comments * Fix coordinator test * Additional review comments * Address code review comments * Remove devices --- CODEOWNERS | 2 + .../components/ourgroceries/__init__.py | 50 ++++ .../components/ourgroceries/config_flow.py | 57 ++++ .../components/ourgroceries/const.py | 3 + .../components/ourgroceries/coordinator.py | 41 +++ .../components/ourgroceries/manifest.json | 9 + .../components/ourgroceries/strings.json | 20 ++ homeassistant/components/ourgroceries/todo.py | 118 +++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ourgroceries/__init__.py | 6 + tests/components/ourgroceries/conftest.py | 68 +++++ .../ourgroceries/test_config_flow.py | 96 +++++++ tests/components/ourgroceries/test_init.py | 55 ++++ tests/components/ourgroceries/test_todo.py | 243 ++++++++++++++++++ 17 files changed, 781 insertions(+) create mode 100644 homeassistant/components/ourgroceries/__init__.py create mode 100644 homeassistant/components/ourgroceries/config_flow.py create mode 100644 homeassistant/components/ourgroceries/const.py create mode 100644 homeassistant/components/ourgroceries/coordinator.py create mode 100644 homeassistant/components/ourgroceries/manifest.json create mode 100644 homeassistant/components/ourgroceries/strings.json create mode 100644 homeassistant/components/ourgroceries/todo.py create mode 100644 tests/components/ourgroceries/__init__.py create mode 100644 tests/components/ourgroceries/conftest.py create mode 100644 tests/components/ourgroceries/test_config_flow.py create mode 100644 tests/components/ourgroceries/test_init.py create mode 100644 tests/components/ourgroceries/test_todo.py diff --git a/CODEOWNERS b/CODEOWNERS index d7c8eca064c..45f5669aebb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -930,6 +930,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oru/ @bvlaicu /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core +/homeassistant/components/ourgroceries/ @OnFreund +/tests/components/ourgroceries/ @OnFreund /homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /homeassistant/components/ovo_energy/ @timmo001 diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py new file mode 100644 index 00000000000..d645b8617c2 --- /dev/null +++ b/homeassistant/components/ourgroceries/__init__.py @@ -0,0 +1,50 @@ +"""The OurGroceries integration.""" +from __future__ import annotations + +from asyncio import TimeoutError as AsyncIOTimeoutError + +from aiohttp import ClientError +from ourgroceries import OurGroceries +from ourgroceries.exceptions import InvalidLoginException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import OurGroceriesDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.TODO] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OurGroceries from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + data = entry.data + og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) + lists = [] + try: + await og.login() + lists = (await og.get_my_lists())["shoppingLists"] + except (AsyncIOTimeoutError, ClientError) as error: + raise ConfigEntryNotReady from error + except InvalidLoginException: + return False + + coordinator = OurGroceriesDataUpdateCoordinator(hass, og, lists) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + 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.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ourgroceries/config_flow.py b/homeassistant/components/ourgroceries/config_flow.py new file mode 100644 index 00000000000..a982325fceb --- /dev/null +++ b/homeassistant/components/ourgroceries/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for OurGroceries integration.""" +from __future__ import annotations + +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging +from typing import Any + +from aiohttp import ClientError +from ourgroceries import OurGroceries +from ourgroceries.exceptions import InvalidLoginException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for OurGroceries.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + og = OurGroceries(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + try: + await og.login() + except (AsyncIOTimeoutError, ClientError): + errors["base"] = "cannot_connect" + except InvalidLoginException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ourgroceries/const.py b/homeassistant/components/ourgroceries/const.py new file mode 100644 index 00000000000..ba0ff789522 --- /dev/null +++ b/homeassistant/components/ourgroceries/const.py @@ -0,0 +1,3 @@ +"""Constants for the OurGroceries integration.""" + +DOMAIN = "ourgroceries" diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py new file mode 100644 index 00000000000..a4b594c7e86 --- /dev/null +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -0,0 +1,41 @@ +"""The OurGroceries coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from ourgroceries import OurGroceries + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +SCAN_INTERVAL = 60 + +_LOGGER = logging.getLogger(__name__) + + +class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage fetching OurGroceries data.""" + + def __init__( + self, hass: HomeAssistant, og: OurGroceries, lists: list[dict] + ) -> None: + """Initialize global OurGroceries data updater.""" + self.og = og + self.lists = lists + interval = timedelta(seconds=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + ) + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch data from OurGroceries.""" + return { + sl["id"]: (await self.og.get_list_items(list_id=sl["id"])) + for sl in self.lists + } diff --git a/homeassistant/components/ourgroceries/manifest.json b/homeassistant/components/ourgroceries/manifest.json new file mode 100644 index 00000000000..ec5a5039b39 --- /dev/null +++ b/homeassistant/components/ourgroceries/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ourgroceries", + "name": "OurGroceries", + "codeowners": ["@OnFreund"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ourgroceries", + "iot_class": "cloud_polling", + "requirements": ["ourgroceries==1.5.4"] +} diff --git a/homeassistant/components/ourgroceries/strings.json b/homeassistant/components/ourgroceries/strings.json new file mode 100644 index 00000000000..96dc8b371d1 --- /dev/null +++ b/homeassistant/components/ourgroceries/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py new file mode 100644 index 00000000000..98029b09ba8 --- /dev/null +++ b/homeassistant/components/ourgroceries/todo.py @@ -0,0 +1,118 @@ +"""A todo platform for OurGroceries.""" + +import asyncio + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OurGroceriesDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the OurGroceries todo platform config entry.""" + coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + OurGroceriesTodoListEntity(coordinator, sl["id"], sl["name"]) + for sl in coordinator.lists + ) + + +class OurGroceriesTodoListEntity( + CoordinatorEntity[OurGroceriesDataUpdateCoordinator], TodoListEntity +): + """An OurGroceries TodoListEntity.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__( + self, + coordinator: OurGroceriesDataUpdateCoordinator, + list_id: str, + list_name: str, + ) -> None: + """Initialize TodoistTodoListEntity.""" + super().__init__(coordinator=coordinator) + self._list_id = list_id + self._attr_unique_id = list_id + self._attr_name = list_name + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data is None: + self._attr_todo_items = None + else: + + def _completion_status(item): + if item.get("crossedOffAt", False): + return TodoItemStatus.COMPLETED + return TodoItemStatus.NEEDS_ACTION + + self._attr_todo_items = [ + TodoItem( + summary=item["name"], + uid=item["id"], + status=_completion_status(item), + ) + for item in self.coordinator.data[self._list_id]["list"]["items"] + ] + super()._handle_coordinator_update() + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Create a To-do item.""" + if item.status != TodoItemStatus.NEEDS_ACTION: + raise ValueError("Only active tasks may be created.") + await self.coordinator.og.add_item_to_list( + self._list_id, item.summary, auto_category=True + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + if item.summary: + api_items = self.coordinator.data[self._list_id]["list"]["items"] + category = next( + api_item["categoryId"] + for api_item in api_items + if api_item["id"] == item.uid + ) + await self.coordinator.og.change_item_on_list( + self._list_id, item.uid, category, item.summary + ) + if item.status is not None: + cross_off = item.status == TodoItemStatus.COMPLETED + await self.coordinator.og.toggle_item_crossed_off( + self._list_id, item.uid, cross_off=cross_off + ) + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete a To-do item.""" + await asyncio.gather( + *[ + self.coordinator.og.remove_item_from_list(self._list_id, uid) + for uid in uids + ] + ) + await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3aa738731b0..fbd0b40551b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -347,6 +347,7 @@ FLOWS = { "opower", "oralb", "otbr", + "ourgroceries", "overkiz", "ovo_energy", "owntracks", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 00ec549fd6d..bfd7a869089 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4152,6 +4152,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "ourgroceries": { + "name": "OurGroceries", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "overkiz": { "name": "Overkiz", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index d1721786f01..4a59ea44c2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1425,6 +1425,9 @@ oru==0.1.11 # homeassistant.components.orvibo orvibo==1.1.1 +# homeassistant.components.ourgroceries +ourgroceries==1.5.4 + # homeassistant.components.ovo_energy ovoenergy==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d4d29c74fa..37db0e05351 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1095,6 +1095,9 @@ opower==0.0.39 # homeassistant.components.oralb oralb-ble==0.17.6 +# homeassistant.components.ourgroceries +ourgroceries==1.5.4 + # homeassistant.components.ovo_energy ovoenergy==1.2.0 diff --git a/tests/components/ourgroceries/__init__.py b/tests/components/ourgroceries/__init__.py new file mode 100644 index 00000000000..67fcb439908 --- /dev/null +++ b/tests/components/ourgroceries/__init__.py @@ -0,0 +1,6 @@ +"""Tests for the OurGroceries integration.""" + + +def items_to_shopping_list(items: list) -> dict[dict[list]]: + """Convert a list of items into a shopping list.""" + return {"list": {"items": items}} diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py new file mode 100644 index 00000000000..7f113da2633 --- /dev/null +++ b/tests/components/ourgroceries/conftest.py @@ -0,0 +1,68 @@ +"""Common fixtures for the OurGroceries tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.ourgroceries import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import items_to_shopping_list + +from tests.common import MockConfigEntry + +USERNAME = "test-username" +PASSWORD = "test-password" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ourgroceries.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="ourgroceries_config_entry") +def mock_ourgroceries_config_entry() -> MockConfigEntry: + """Mock ourgroceries configuration.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + +@pytest.fixture(name="items") +def mock_items() -> dict: + """Mock a collection of shopping list items.""" + return [] + + +@pytest.fixture(name="ourgroceries") +def mock_ourgroceries(items: list[dict]) -> AsyncMock: + """Mock the OurGroceries api.""" + og = AsyncMock() + og.login.return_value = True + og.get_my_lists.return_value = { + "shoppingLists": [{"id": "test_list", "name": "Test List"}] + } + og.get_list_items.return_value = items_to_shopping_list(items) + return og + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + ourgroceries: AsyncMock, + ourgroceries_config_entry: MockConfigEntry, +) -> None: + """Mock setup of the ourgroceries integration.""" + ourgroceries_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.ourgroceries.OurGroceries", return_value=ourgroceries + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield diff --git a/tests/components/ourgroceries/test_config_flow.py b/tests/components/ourgroceries/test_config_flow.py new file mode 100644 index 00000000000..f9d274125c1 --- /dev/null +++ b/tests/components/ourgroceries/test_config_flow.py @@ -0,0 +1,96 @@ +"""Test the OurGroceries config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.ourgroceries.config_flow import ( + AsyncIOTimeoutError, + ClientError, + InvalidLoginException, +) +from homeassistant.components.ourgroceries.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidLoginException, "invalid_auth"), + (ClientError, "cannot_connect"), + (AsyncIOTimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_error( + hass: HomeAssistant, exception: Exception, error: str, mock_setup_entry: AsyncMock +) -> None: + """Test we handle form errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "test-username" + assert result3["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ourgroceries/test_init.py b/tests/components/ourgroceries/test_init.py new file mode 100644 index 00000000000..ef96c5e811c --- /dev/null +++ b/tests/components/ourgroceries/test_init.py @@ -0,0 +1,55 @@ +"""Unit tests for the OurGroceries integration.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.ourgroceries import ( + AsyncIOTimeoutError, + ClientError, + InvalidLoginException, +) +from homeassistant.components.ourgroceries.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload( + hass: HomeAssistant, + setup_integration: None, + ourgroceries_config_entry: MockConfigEntry | None, +) -> None: + """Test loading and unloading of the config entry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert ourgroceries_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(ourgroceries_config_entry.entry_id) + assert ourgroceries_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.fixture +def login_with_error(exception, ourgroceries: AsyncMock): + """Fixture to simulate error on login.""" + ourgroceries.login.side_effect = (exception,) + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (InvalidLoginException, ConfigEntryState.SETUP_ERROR), + (ClientError, ConfigEntryState.SETUP_RETRY), + (AsyncIOTimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_init_failure( + hass: HomeAssistant, + login_with_error, + setup_integration: None, + status: ConfigEntryState, + ourgroceries_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + assert ourgroceries_config_entry.state == status diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py new file mode 100644 index 00000000000..65bbff0e601 --- /dev/null +++ b/tests/components/ourgroceries/test_todo.py @@ -0,0 +1,243 @@ +"""Unit tests for the OurGroceries todo platform.""" +from asyncio import TimeoutError as AsyncIOTimeoutError +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.ourgroceries.coordinator import SCAN_INTERVAL +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from . import items_to_shopping_list + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize( + ("items", "expected_state"), + [ + ([], "0"), + ([{"id": "12345", "name": "Soda"}], "1"), + ([{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}], "0"), + ( + [ + {"id": "12345", "name": "Soda"}, + {"id": "54321", "name": "Milk"}, + ], + "2", + ), + ], +) +async def test_todo_item_state( + hass: HomeAssistant, + setup_integration: None, + expected_state: str, +) -> None: + """Test for a shopping list entity state.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == expected_state + + +async def test_add_todo_list_item( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for adding an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + ourgroceries.add_item_to_list = AsyncMock() + # Fake API response when state is refreshed after create + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Soda"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + + args = ourgroceries.add_item_to_list.call_args + assert args + assert args.args == ("test_list", "Soda") + assert args.kwargs.get("auto_category") is True + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize(("items"), [[{"id": "12345", "name": "Soda"}]]) +async def test_update_todo_item_status( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for updating the completion status of an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + ourgroceries.toggle_item_crossed_off = AsyncMock() + + # Fake API response when state is refreshed after crossing off + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "status": "completed"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.toggle_item_crossed_off.called + args = ourgroceries.toggle_item_crossed_off.call_args + assert args + assert args.args == ("test_list", "12345") + assert args.kwargs.get("cross_off") is True + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + # Fake API response when state is refreshed after reopen + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "status": "needs_action"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.toggle_item_crossed_off.called + args = ourgroceries.toggle_item_crossed_off.call_args + assert args + assert args.args == ("test_list", "12345") + assert args.kwargs.get("cross_off") is False + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + ("items"), [[{"id": "12345", "name": "Soda", "categoryId": "test_category"}]] +) +async def test_update_todo_item_summary( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for updating an item summary.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + ourgroceries.change_item_on_list = AsyncMock() + + # Fake API response when state is refreshed update + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Milk"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "rename": "Milk"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.change_item_on_list + args = ourgroceries.change_item_on_list.call_args + assert args.args == ("test_list", "12345", "test_category", "Milk") + + +@pytest.mark.parametrize( + ("items"), + [ + [ + {"id": "12345", "name": "Soda"}, + {"id": "54321", "name": "Milk"}, + ] + ], +) +async def test_remove_todo_item( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for removing an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "2" + + ourgroceries.remove_item_from_list = AsyncMock() + # Fake API response when state is refreshed after remove + ourgroceries.get_list_items.return_value = items_to_shopping_list([]) + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["12345", "54321"]}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.remove_item_from_list.call_count == 2 + args = ourgroceries.remove_item_from_list.call_args_list + assert args[0].args == ("test_list", "12345") + assert args[1].args == ("test_list", "54321") + + await async_update_entity(hass, "todo.test_list") + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + +@pytest.mark.parametrize( + ("exception"), + [ + (ClientError), + (AsyncIOTimeoutError), + ], +) +async def test_coordinator_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: None, + ourgroceries: AsyncMock, + exception: Exception, +) -> None: + """Test error on coordinator update.""" + state = hass.states.get("todo.test_list") + assert state.state == "0" + + ourgroceries.get_list_items.side_effect = exception + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("todo.test_list") + assert state.state == STATE_UNAVAILABLE From be889c89c1b7d719ccd2d08e40b08335065d4a4c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 26 Nov 2023 17:49:51 +0100 Subject: [PATCH 769/982] Update modbus validate table to be 3 state, to simplify the code (#104514) --- homeassistant/components/modbus/validators.py | 85 ++++++++++++------- tests/components/modbus/test_sensor.py | 4 +- 2 files changed, 56 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index fbf56d97b51..52919a24ac7 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -52,6 +52,12 @@ ENTRY = namedtuple( "validate_parm", ], ) + + +ILLEGAL = "I" +OPTIONAL = "O" +DEMANDED = "D" + PARM_IS_LEGAL = namedtuple( "PARM_IS_LEGAL", [ @@ -62,28 +68,40 @@ PARM_IS_LEGAL = namedtuple( "swap_word", ], ) -# PARM_IS_LEGAL defines if the keywords: -# count: -# structure: -# swap: byte -# swap: word -# swap: word_byte (identical to swap: word) -# are legal to use. -# These keywords are only legal with some datatype: ... -# As expressed in DEFAULT_STRUCT_FORMAT - DEFAULT_STRUCT_FORMAT = { - DataType.INT16: ENTRY("h", 1, PARM_IS_LEGAL(False, False, True, True, False)), - DataType.UINT16: ENTRY("H", 1, PARM_IS_LEGAL(False, False, True, True, False)), - DataType.FLOAT16: ENTRY("e", 1, PARM_IS_LEGAL(False, False, True, True, False)), - DataType.INT32: ENTRY("i", 2, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.UINT32: ENTRY("I", 2, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.FLOAT32: ENTRY("f", 2, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.INT64: ENTRY("q", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.UINT64: ENTRY("Q", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.FLOAT64: ENTRY("d", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.STRING: ENTRY("s", -1, PARM_IS_LEGAL(True, False, False, True, False)), - DataType.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)), + DataType.INT16: ENTRY( + "h", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL) + ), + DataType.UINT16: ENTRY( + "H", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL) + ), + DataType.FLOAT16: ENTRY( + "e", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL) + ), + DataType.INT32: ENTRY( + "i", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.UINT32: ENTRY( + "I", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.FLOAT32: ENTRY( + "f", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.INT64: ENTRY( + "q", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.UINT64: ENTRY( + "Q", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.FLOAT64: ENTRY( + "d", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.STRING: ENTRY( + "s", 0, PARM_IS_LEGAL(DEMANDED, ILLEGAL, ILLEGAL, OPTIONAL, ILLEGAL) + ), + DataType.CUSTOM: ENTRY( + "?", 0, PARM_IS_LEGAL(DEMANDED, DEMANDED, ILLEGAL, ILLEGAL, ILLEGAL) + ), } @@ -96,32 +114,37 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: data_type = config[CONF_DATA_TYPE] = DataType.INT16 count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) - slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT, 0)) + slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT)) swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm for entry in ( (count, validator.count, CONF_COUNT), (structure, validator.structure, CONF_STRUCTURE), + ( + slave_count, + validator.slave_count, + f"{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}", + ), ): - if bool(entry[0]) != entry[1]: - error = "cannot be combined" if not entry[1] else "missing, demanded" + if entry[0] is None: + if entry[1] == DEMANDED: + error = f"{name}: `{entry[2]}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + elif entry[1] == ILLEGAL: error = ( - f"{name}: `{entry[2]}:` {error} with `{CONF_DATA_TYPE}: {data_type}`" + f"{name}: `{entry[2]}:` illegal with `{CONF_DATA_TYPE}: {data_type}`" ) raise vol.Invalid(error) - if slave_count and not validator.slave_count: - error = f"{name}: `{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}:` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) if swap_type != CONF_SWAP_NONE: swap_type_validator = { - CONF_SWAP_NONE: False, + CONF_SWAP_NONE: validator.swap_byte, CONF_SWAP_BYTE: validator.swap_byte, CONF_SWAP_WORD: validator.swap_word, CONF_SWAP_WORD_BYTE: validator.swap_word, }[swap_type] - if not swap_type_validator: - error = f"{name}: `{CONF_SWAP}:{swap_type}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + if swap_type_validator == ILLEGAL: + error = f"{name}: `{CONF_SWAP}:{swap_type}` illegal with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) if config[CONF_DATA_TYPE] == DataType.CUSTOM: try: diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 51202ded191..d0a4e23f780 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -247,7 +247,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `{CONF_STRUCTURE}:` missing, demanded with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", + f"{TEST_ENTITY_NAME}: Size of structure is 0 bytes but `{CONF_COUNT}: 4` is 8 bytes", ), ( { @@ -276,7 +276,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `{CONF_SWAP}:{CONF_SWAP_WORD}` cannot be combined with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", + f"{TEST_ENTITY_NAME}: `{CONF_SWAP}:{CONF_SWAP_WORD}` illegal with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ], ) From 087efb754555377b7d8af8cab2f634de6e5dcf79 Mon Sep 17 00:00:00 2001 From: dotvav Date: Sun, 26 Nov 2023 17:55:48 +0100 Subject: [PATCH 770/982] Add Hitachi air to air heat pumps to the Climate platform (#104517) --- homeassistant/components/overkiz/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index b242f6db8e2..0f30f64444b 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -99,6 +99,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) + UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_DHW: Platform.WATER_HEATER, # widgetName, uiClass is HitachiHeatingSystem (not supported) UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.MY_FOX_SECURITY_CAMERA: Platform.SWITCH, # widgetName, uiClass is Camera (not supported) From 06b74249f77768eb610a1fd0eea46a9105d3dca8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Nov 2023 12:48:35 -0600 Subject: [PATCH 771/982] Bump aioesphomeapi to 19.1.0 (#104557) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 60f34c23779..22821d56592 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==19.0.1", + "aioesphomeapi==19.1.0", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 4a59ea44c2a..1405e2836a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.0.1 +aioesphomeapi==19.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 37db0e05351..3542ac132b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.0.1 +aioesphomeapi==19.1.0 # homeassistant.components.flo aioflo==2021.11.0 From 53e78cb0171b2ce137133a92276c25aa257e9cc2 Mon Sep 17 00:00:00 2001 From: Hessel Date: Sun, 26 Nov 2023 20:40:27 +0100 Subject: [PATCH 772/982] Wallbox Change Minimum Value Charging Current (#104553) --- homeassistant/components/wallbox/number.py | 2 +- tests/components/wallbox/test_number.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 9694e13103c..b47eb14d58a 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -35,7 +35,7 @@ def min_charging_current_value(coordinator: WallboxCoordinator) -> float: in BIDIRECTIONAL_MODEL_PREFIXES ): return cast(float, (coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] * -1)) - return 0 + return 6 @dataclass diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 738b9bf7bd6..837df4dfd47 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -43,7 +43,7 @@ async def test_wallbox_number_class( status_code=200, ) state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - assert state.attributes["min"] == 0 + assert state.attributes["min"] == 6 assert state.attributes["max"] == 25 await hass.services.async_call( From b49505b3905a42127f39ce8a8e2d3199699d38c9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 26 Nov 2023 20:45:45 +0100 Subject: [PATCH 773/982] Add reauth flow to co2signal (#104507) --- .../components/co2signal/config_flow.py | 58 ++++++++++++++----- .../components/co2signal/coordinator.py | 4 +- homeassistant/components/co2signal/helpers.py | 4 +- .../components/co2signal/strings.json | 8 ++- .../components/co2signal/test_config_flow.py | 40 +++++++++++++ tests/components/co2signal/test_sensor.py | 23 +++++++- 6 files changed, 117 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 4f445238a06..234c1c01392 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Co2signal integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from aioelectricitymaps import ElectricityMaps @@ -8,6 +9,7 @@ from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -32,6 +34,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _data: dict | None + _reauth_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -113,25 +116,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "country", data_schema, {**self._data, **user_input} ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle the reauth step.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + } + ) + return await self._validate_and_create("reauth", data_schema, entry_data) + async def _validate_and_create( - self, step_id: str, data_schema: vol.Schema, data: dict + self, step_id: str, data_schema: vol.Schema, data: Mapping[str, Any] ) -> FlowResult: """Validate data and show form if it is invalid.""" errors: dict[str, str] = {} - session = async_get_clientsession(self.hass) - em = ElectricityMaps(token=data[CONF_API_KEY], session=session) - try: - await fetch_latest_carbon_intensity(self.hass, em, data) - except InvalidToken: - errors["base"] = "invalid_auth" - except ElectricityMapsError: - errors["base"] = "unknown" - else: - return self.async_create_entry( - title=get_extra_name(data) or "CO2 Signal", - data=data, - ) + if data: + session = async_get_clientsession(self.hass) + em = ElectricityMaps(token=data[CONF_API_KEY], session=session) + + try: + await fetch_latest_carbon_intensity(self.hass, em, data) + except InvalidToken: + errors["base"] = "invalid_auth" + except ElectricityMapsError: + errors["base"] = "unknown" + else: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={ + CONF_API_KEY: data[CONF_API_KEY], + }, + ) + await self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry( + title=get_extra_name(data) or "CO2 Signal", + data=data, + ) return self.async_show_form( step_id=step_id, diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 7c0fe72e60a..115c976b465 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -10,7 +10,7 @@ from aioelectricitymaps.models import CarbonIntensityResponse from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -44,6 +44,6 @@ class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): self.hass, self.client, self.config_entry.data ) except InvalidToken as err: - raise ConfigEntryError from err + raise ConfigEntryAuthFailed from err except ElectricityMapsError as err: raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py index f794a4b0573..43579c162e2 100644 --- a/homeassistant/components/co2signal/helpers.py +++ b/homeassistant/components/co2signal/helpers.py @@ -1,5 +1,5 @@ """Helper functions for the CO2 Signal integration.""" -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from aioelectricitymaps import ElectricityMaps @@ -14,7 +14,7 @@ from .const import CONF_COUNTRY_CODE async def fetch_latest_carbon_intensity( hass: HomeAssistant, em: ElectricityMaps, - config: dict[str, Any] | MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> CarbonIntensityResponse: """Fetch the latest carbon intensity based on country code or location coordinates.""" if CONF_COUNTRY_CODE in config: diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 4564fdf14be..89289dd816d 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -18,6 +18,11 @@ "data": { "country_code": "Country code" } + }, + "reauth": { + "data": { + "api_key": "[%key:common::config_flow::data::access_token%]" + } } }, "error": { @@ -28,7 +33,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "api_ratelimit": "[%key:component::co2signal::config::error::api_ratelimit%]" + "api_ratelimit": "[%key:component::co2signal::config::error::api_ratelimit%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index b717e159986..5b1ade1ee49 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -10,9 +10,12 @@ import pytest from homeassistant import config_entries from homeassistant.components.co2signal import DOMAIN, config_flow +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + @pytest.mark.usefixtures("electricity_maps") async def test_form_home(hass: HomeAssistant) -> None: @@ -186,3 +189,40 @@ async def test_form_error_handling( assert result["data"] == { "api_key": "api_key", } + + +async def test_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, + electricity_maps: AsyncMock, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + + init_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=None, + ) + + assert init_result["type"] == FlowResultType.FORM + assert init_result["step_id"] == "reauth" + + with patch( + "homeassistant.components.co2signal.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + configure_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + { + CONF_API_KEY: "api_key2", + }, + ) + await hass.async_block_till_done() + + assert configure_result["type"] == FlowResultType.ABORT + assert configure_result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py index 4fe3e28b991..b79c8e04c23 100644 --- a/tests/components/co2signal/test_sensor.py +++ b/tests/components/co2signal/test_sensor.py @@ -42,7 +42,6 @@ async def test_sensor( @pytest.mark.parametrize( "error", [ - InvalidToken, ElectricityMapsDecodeError, ElectricityMapsError, Exception, @@ -82,3 +81,25 @@ async def test_sensor_update_fail( assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) assert state.state == "45.9862319009581" assert len(electricity_maps.mock_calls) == 3 + + +@pytest.mark.usefixtures("setup_integration") +async def test_sensor_reauth_triggered( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + electricity_maps: AsyncMock, +): + """Test if reauth flow is triggered.""" + assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) + assert state.state == "45.9862319009581" + + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = InvalidToken + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = InvalidToken + + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (flows := hass.config_entries.flow.async_progress()) + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth" From 670e5a2eae0a49ffd0552b7171bac9f2b363b20f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 26 Nov 2023 22:05:50 +0100 Subject: [PATCH 774/982] Bump `nettigo-air-monitor` to version 2.2.2 (#104562) Bump nettigo-air-monitor to version 2.2.2 --- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 8d4396d5d80..a4ef9af9aee 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==2.2.1"], + "requirements": ["nettigo-air-monitor==2.2.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 1405e2836a4..86588493416 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1298,7 +1298,7 @@ netdata==1.1.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.2.1 +nettigo-air-monitor==2.2.2 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3542ac132b0..69d8adfd2ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ nessclient==1.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.2.1 +nettigo-air-monitor==2.2.2 # homeassistant.components.nexia nexia==2.0.7 From 321b24b14606baa105af42107cbdb816160c06a7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Nov 2023 07:47:46 +0100 Subject: [PATCH 775/982] Improve user-facing error messages in HomeWizard Energy (#104547) --- .../components/homewizard/helpers.py | 13 ++++++++++-- .../components/homewizard/strings.json | 8 ++++++++ tests/components/homewizard/test_button.py | 10 ++++++++-- tests/components/homewizard/test_number.py | 10 ++++++++-- tests/components/homewizard/test_switch.py | 20 +++++++++++++++---- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index 3f7fc064931..4f12a4f9726 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -8,6 +8,7 @@ from homewizard_energy.errors import DisabledError, RequestError from homeassistant.exceptions import HomeAssistantError +from .const import DOMAIN from .entity import HomeWizardEntity _HomeWizardEntityT = TypeVar("_HomeWizardEntityT", bound=HomeWizardEntity) @@ -30,11 +31,19 @@ def homewizard_exception_handler( try: await func(self, *args, **kwargs) except RequestError as ex: - raise HomeAssistantError from ex + raise HomeAssistantError( + "An error occurred while communicating with HomeWizard device", + translation_domain=DOMAIN, + translation_key="communication_error", + ) from ex except DisabledError as ex: await self.hass.config_entries.async_reload( self.coordinator.config_entry.entry_id ) - raise HomeAssistantError from ex + raise HomeAssistantError( + "The local API of the HomeWizard device is disabled", + translation_domain=DOMAIN, + translation_key="api_disabled", + ) from ex return handler diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 3bc55b3c848..acdb321d6ff 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -167,5 +167,13 @@ "name": "Cloud connection" } } + }, + "exceptions": { + "api_disabled": { + "message": "The local API of the HomeWizard device is disabled" + }, + "communication_error": { + "message": "An error occurred while communicating with HomeWizard device" + } } } diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index 25ef73e1459..c25a4ed0f4e 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -58,7 +58,10 @@ async def test_identify_button( # Raise RequestError when identify is called mock_homewizardenergy.identify.side_effect = RequestError() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, @@ -73,7 +76,10 @@ async def test_identify_button( # Raise RequestError when identify is called mock_homewizardenergy.identify.side_effect = DisabledError() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", + ): await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 9af4cac665c..ebd8d80ece2 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -67,7 +67,10 @@ async def test_number_entities( mock_homewizardenergy.state_set.assert_called_with(brightness=127) mock_homewizardenergy.state_set.side_effect = RequestError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, @@ -79,7 +82,10 @@ async def test_number_entities( ) mock_homewizardenergy.state_set.side_effect = DisabledError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", + ): await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 0b88f1ca949..2f6e777a3a8 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -120,7 +120,10 @@ async def test_switch_entities( # Test request error handling mocked_method.side_effect = RequestError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -128,7 +131,10 @@ async def test_switch_entities( blocking=True, ) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, @@ -139,7 +145,10 @@ async def test_switch_entities( # Test disabled error handling mocked_method.side_effect = DisabledError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -147,7 +156,10 @@ async def test_switch_entities( blocking=True, ) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, From 7fbf68fd1176d53eff709872f39325e78d3436a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Nov 2023 01:56:06 -0600 Subject: [PATCH 776/982] Bump aioesphomeapi to 19.1.1 (#104569) - Fixes races in bluetooth connections - The client now has 100% coverage - The library is approaching ~100% coverage - Minor performance improvement changelog: https://github.com/esphome/aioesphomeapi/compare/v19.1.0...v19.1.1 coverage: https://app.codecov.io/gh/esphome/aioesphomeapi/tree/main --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 22821d56592..26eaac23895 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==19.1.0", + "aioesphomeapi==19.1.1", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 86588493416..7a9b4508ff4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.0 +aioesphomeapi==19.1.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69d8adfd2ed..8e03450762d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.0 +aioesphomeapi==19.1.1 # homeassistant.components.flo aioflo==2021.11.0 From 95c771e330023ea72f3c8e25345e263e997c4665 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 27 Nov 2023 21:00:39 +1300 Subject: [PATCH 777/982] Send esphome tts_stream event after audio bytes are actually loaded into memory (#104448) Send tts_stream event after audio bytes are actually loaded into memory --- .../components/esphome/voice_assistant.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index bb62d495076..68ed98aa789 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -301,10 +301,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): if self.transport is None: return - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} - ) - extension, data = await tts.async_get_media_source_audio( self.hass, media_id, @@ -331,11 +327,17 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): audio_bytes = wav_file.readframes(wav_file.getnframes()) - _LOGGER.debug("Sending %d bytes of audio", len(audio_bytes)) + audio_bytes_size = len(audio_bytes) + + _LOGGER.debug("Sending %d bytes of audio", audio_bytes_size) + + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} + ) bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8 sample_offset = 0 - samples_left = len(audio_bytes) // bytes_per_sample + samples_left = audio_bytes_size // bytes_per_sample while samples_left > 0: bytes_offset = sample_offset * bytes_per_sample From 5ba70ef2cbce2e7026f91428913cae82be57d8b7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Nov 2023 09:19:58 +0100 Subject: [PATCH 778/982] Fix AccessDeniedException handling in Renault (#104574) --- homeassistant/components/renault/coordinator.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index d101b551dfe..f8e6a21823a 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -45,6 +45,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): ) self.access_denied = False self.not_supported = False + self._has_already_worked = False async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" @@ -52,11 +53,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): raise NotImplementedError("Update method not implemented") try: async with _PARALLEL_SEMAPHORE: - return await self.update_method() + data = await self.update_method() + self._has_already_worked = True + return data + except AccessDeniedException as err: - # Disable because the account is not allowed to access this Renault endpoint. - self.update_interval = None - self.access_denied = True + # This can mean both a temporary error or a permanent error. If it has + # worked before, make it temporary, if not disable the update interval. + if not self._has_already_worked: + self.update_interval = None + self.access_denied = True raise UpdateFailed(f"This endpoint is denied: {err}") from err except NotSupportedException as err: From b3ff30a9c8df5d01bfce3a92e0b7a5444bfc1dc0 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 27 Nov 2023 09:49:46 +0100 Subject: [PATCH 779/982] Bump `accuweather` to version 2.1.1 (#104563) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index b74711ccbe6..2974c36607b 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==2.1.0"] + "requirements": ["accuweather==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a9b4508ff4..3d2d44add3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -143,7 +143,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.1.0 +accuweather==2.1.1 # homeassistant.components.adax adax==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e03450762d..f3bd472c386 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -122,7 +122,7 @@ Tami4EdgeAPI==2.1 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.1.0 +accuweather==2.1.1 # homeassistant.components.adax adax==0.3.0 From 9907e11b03aee0bc0245794b58c605f116a2f92f Mon Sep 17 00:00:00 2001 From: ufodone <35497351+ufodone@users.noreply.github.com> Date: Mon, 27 Nov 2023 00:59:18 -0800 Subject: [PATCH 780/982] Remove code owner for envisalink integration (#103864) --- CODEOWNERS | 1 - homeassistant/components/envisalink/manifest.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 45f5669aebb..7b8a5ea5f87 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -350,7 +350,6 @@ build.json @home-assistant/supervisor /homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie -/homeassistant/components/envisalink/ @ufodone /homeassistant/components/ephember/ @ttroy50 /homeassistant/components/epson/ @pszafer /tests/components/epson/ @pszafer diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index c048687c906..093ebf77eba 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -1,7 +1,7 @@ { "domain": "envisalink", "name": "Envisalink", - "codeowners": ["@ufodone"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/envisalink", "iot_class": "local_push", "loggers": ["pyenvisalink"], From ccc88049060ed5c09445db04593561d45ac4ef8f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 27 Nov 2023 10:53:41 +0100 Subject: [PATCH 781/982] Bump aiowithings to 2.0.0 (#104579) --- .../components/withings/manifest.json | 2 +- homeassistant/components/withings/sensor.py | 14 +++-- .../components/withings/strings.json | 8 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../withings/snapshots/test_sensor.ambr | 62 ++++++++++--------- 6 files changed, 47 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index e2357e78fb8..fe5704d119c 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==1.0.3"] + "requirements": ["aiowithings==2.0.0"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index b7ef6c6852b..36ac9ea7d73 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -424,10 +424,11 @@ ACTIVITY_SENSORS = [ ), WithingsActivitySensorEntityDescription( key="activity_floors_climbed_today", - value_fn=lambda activity: activity.floors_climbed, - translation_key="activity_floors_climbed_today", + value_fn=lambda activity: activity.elevation, + translation_key="activity_elevation_today", icon="mdi:stairs-up", - native_unit_of_measurement="floors", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -568,10 +569,11 @@ WORKOUT_SENSORS = [ ), WithingsWorkoutSensorEntityDescription( key="workout_floors_climbed", - value_fn=lambda workout: workout.floors_climbed, - translation_key="workout_floors_climbed", + value_fn=lambda workout: workout.elevation, + translation_key="workout_elevation", icon="mdi:stairs-up", - native_unit_of_measurement="floors", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, ), WithingsWorkoutSensorEntityDescription( key="workout_intensity", diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index fc24c1f5325..ffbbd9acc2b 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -158,8 +158,8 @@ "activity_distance_today": { "name": "Distance travelled today" }, - "activity_floors_climbed_today": { - "name": "Floors climbed today" + "activity_elevation_today": { + "name": "Elevation change today" }, "activity_soft_duration_today": { "name": "Soft activity today" @@ -239,8 +239,8 @@ "workout_distance": { "name": "Distance travelled last workout" }, - "workout_floors_climbed": { - "name": "Floors climbed last workout" + "workout_elevation": { + "name": "Elevation change last workout" }, "workout_intensity": { "name": "Last workout intensity" diff --git a/requirements_all.txt b/requirements_all.txt index 3d2d44add3c..45cd6999f2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -392,7 +392,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.3 +aiowithings==2.0.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3bd472c386..a70c14781a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -365,7 +365,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.3 +aiowithings==2.0.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 59d9b470247..4ca4093e3b8 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -178,6 +178,38 @@ 'state': '1020.121', }) # --- +# name: test_all_entities[sensor.henk_elevation_change_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Elevation change last workout', + 'icon': 'mdi:stairs-up', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_elevation_change_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_all_entities[sensor.henk_elevation_change_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Elevation change today', + 'icon': 'mdi:stairs-up', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_elevation_change_today', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[sensor.henk_extracellular_water] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -237,36 +269,6 @@ 'state': '0.07', }) # --- -# name: test_all_entities[sensor.henk_floors_climbed_last_workout] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Floors climbed last workout', - 'icon': 'mdi:stairs-up', - 'unit_of_measurement': 'floors', - }), - 'context': , - 'entity_id': 'sensor.henk_floors_climbed_last_workout', - 'last_changed': , - 'last_updated': , - 'state': '4', - }) -# --- -# name: test_all_entities[sensor.henk_floors_climbed_today] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Floors climbed today', - 'icon': 'mdi:stairs-up', - 'last_reset': '2023-10-20T00:00:00-07:00', - 'state_class': , - 'unit_of_measurement': 'floors', - }), - 'context': , - 'entity_id': 'sensor.henk_floors_climbed_today', - 'last_changed': , - 'last_updated': , - 'state': '0', - }) -# --- # name: test_all_entities[sensor.henk_heart_pulse] StateSnapshot({ 'attributes': ReadOnlyDict({ From 669b347ed13d4e5b38a67721127737d7ca8f2737 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 27 Nov 2023 05:13:40 -0500 Subject: [PATCH 782/982] Add init test to Blink (#103263) --- .coveragerc | 1 - homeassistant/components/blink/__init__.py | 4 +- homeassistant/components/blink/const.py | 1 + tests/components/blink/conftest.py | 12 +- tests/components/blink/test_init.py | 285 +++++++++++++++++++++ 5 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 tests/components/blink/test_init.py diff --git a/.coveragerc b/.coveragerc index 884afdcf408..1075c0c3e35 100644 --- a/.coveragerc +++ b/.coveragerc @@ -115,7 +115,6 @@ omit = homeassistant/components/beewi_smartclim/sensor.py homeassistant/components/bitcoin/sensor.py homeassistant/components/bizkaibus/sensor.py - homeassistant/components/blink/__init__.py homeassistant/components/blink/alarm_control_panel.py homeassistant/components/blink/binary_sensor.py homeassistant/components/blink/camera.py diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index c6413dd4372..7c586a94c3c 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -158,8 +158,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Blink entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - return True + if len(hass.data[DOMAIN]) > 0: + return unload_ok hass.services.async_remove(DOMAIN, SERVICE_REFRESH) hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index d394b5c0008..64b05e1ba27 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -7,6 +7,7 @@ DEVICE_ID = "Home Assistant" CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" +CONF_DEVICE_ID = "device_id" DEFAULT_BRAND = "Blink" DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" DEFAULT_SCAN_INTERVAL = 300 diff --git a/tests/components/blink/conftest.py b/tests/components/blink/conftest.py index 382a1689595..4a731b0a8ee 100644 --- a/tests/components/blink/conftest.py +++ b/tests/components/blink/conftest.py @@ -65,12 +65,14 @@ def blink_api_fixture(camera) -> MagicMock: @pytest.fixture(name="mock_blink_auth_api") -def blink_auth_api_fixture(): +def blink_auth_api_fixture() -> MagicMock: """Set up Blink API fixture.""" - with patch( - "homeassistant.components.blink.Auth", autospec=True - ) as mock_blink_auth_api: - mock_blink_auth_api.check_key_required.return_value = False + mock_blink_auth_api = create_autospec(blinkpy.auth.Auth, instance=True) + mock_blink_auth_api.check_key_required.return_value = False + mock_blink_auth_api.send_auth_key = AsyncMock(return_value=True) + + with patch("homeassistant.components.blink.Auth", autospec=True) as class_mock: + class_mock.return_value = mock_blink_auth_api yield mock_blink_auth_api diff --git a/tests/components/blink/test_init.py b/tests/components/blink/test_init.py new file mode 100644 index 00000000000..76f4a6370e8 --- /dev/null +++ b/tests/components/blink/test_init.py @@ -0,0 +1,285 @@ +"""Test the Blink init.""" +import asyncio +from unittest.mock import AsyncMock, MagicMock, Mock + +from aiohttp import ClientError +import pytest + +from homeassistant.components.blink.const import ( + DOMAIN, + SERVICE_REFRESH, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_SEND_PIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME, CONF_NAME, CONF_PIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +CAMERA_NAME = "Camera 1" +FILENAME = "blah" +PIN = "1234" + + +@pytest.mark.parametrize( + ("the_error", "available"), + [(ClientError, False), (asyncio.TimeoutError, False), (None, False)], +) +async def test_setup_not_ready( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + the_error, + available, +) -> None: + """Test setup failed because we can't connect to the Blink system.""" + + mock_blink_api.start = AsyncMock(side_effect=the_error) + mock_blink_api.available = available + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_not_ready_authkey_required( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup failed because 2FA is needed to connect to the Blink system.""" + + mock_blink_auth_api.check_key_required = MagicMock(return_value=True) + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test being able to unload an entry.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.services.has_service(DOMAIN, SERVICE_REFRESH) + assert not hass.services.has_service(DOMAIN, SERVICE_SAVE_VIDEO) + assert not hass.services.has_service(DOMAIN, SERVICE_SEND_PIN) + + +async def test_unload_entry_multiple( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test being able to unload one of 2 entries.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + hass.data[DOMAIN]["dummy"] = {1: 2} + assert mock_config_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert hass.services.has_service(DOMAIN, SERVICE_REFRESH) + assert hass.services.has_service(DOMAIN, SERVICE_SAVE_VIDEO) + assert hass.services.has_service(DOMAIN, SERVICE_SEND_PIN) + + +async def test_migrate_V0( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration script version 0.""" + + mock_config_entry.version = 0 + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize(("version"), [1, 2]) +async def test_migrate( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + version, +) -> None: + """Test migration scripts.""" + + mock_config_entry.version = version + mock_config_entry.data = {**mock_config_entry.data, "login_response": "Blah"} + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_refresh_service_calls( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test refrest service calls.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH, + blocking=True, + ) + + assert mock_blink_api.refresh.call_count == 2 + + +async def test_video_service_calls( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test video service calls.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + caplog.clear() + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + {CONF_NAME: CAMERA_NAME, CONF_FILENAME: FILENAME}, + blocking=True, + ) + assert "no access to path!" in caplog.text + + hass.config.is_allowed_path = Mock(return_value=True) + caplog.clear() + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + {CONF_NAME: CAMERA_NAME, CONF_FILENAME: FILENAME}, + blocking=True, + ) + mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() + + mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) + caplog.clear() + + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + {CONF_NAME: CAMERA_NAME, CONF_FILENAME: FILENAME}, + blocking=True, + ) + assert "Can't write image" in caplog.text + + hass.config.is_allowed_path = Mock(return_value=False) + + +async def test_picture_service_calls( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test picture servcie calls.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + caplog.clear() + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + {CONF_NAME: CAMERA_NAME, CONF_FILE_PATH: FILENAME}, + blocking=True, + ) + assert "no access to path!" in caplog.text + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + {CONF_NAME: CAMERA_NAME, CONF_FILE_PATH: FILENAME}, + blocking=True, + ) + mock_blink_api.cameras[CAMERA_NAME].save_recent_clips.assert_awaited_once() + + mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( + side_effect=OSError + ) + caplog.clear() + + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + {CONF_NAME: CAMERA_NAME, CONF_FILE_PATH: FILENAME}, + blocking=True, + ) + assert "Can't write recent clips to directory" in caplog.text + + +async def test_pin_service_calls( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pin service calls.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + {CONF_PIN: PIN}, + blocking=True, + ) + assert mock_blink_api.auth.send_auth_key.assert_awaited_once From b4553f19a18b75641414482a4ead8bcc7e84e9bf Mon Sep 17 00:00:00 2001 From: CodingSquirrel <13072675+CodingSquirrel@users.noreply.github.com> Date: Mon, 27 Nov 2023 05:16:10 -0500 Subject: [PATCH 783/982] Poll econet water heater once an hour (#90961) --- homeassistant/components/econet/__init__.py | 8 +++----- homeassistant/components/econet/climate.py | 1 + .../components/econet/water_heater.py | 18 +++++------------- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 36cdeb68821..67cbd7496e3 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -1,4 +1,5 @@ """Support for EcoNet products.""" +import asyncio from datetime import timedelta import logging @@ -80,14 +81,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.async_add_executor_job(api.unsubscribe) api.subscribe() - async def fetch_update(now): - """Fetch the latest changes from the API.""" + # Refresh values + await asyncio.sleep(60) await api.refresh_equipment() config_entry.async_on_unload(async_track_time_interval(hass, resubscribe, INTERVAL)) - config_entry.async_on_unload( - async_track_time_interval(hass, fetch_update, INTERVAL + timedelta(minutes=1)) - ) return True diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index e77c4face74..f5328da4776 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -64,6 +64,7 @@ async def async_setup_entry( class EcoNetThermostat(EcoNetEntity, ClimateEntity): """Define an Econet thermostat.""" + _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT def __init__(self, thermostat): diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index cbaf4551d03..a99ab087729 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -1,4 +1,5 @@ """Support for Rheem EcoNet water heaters.""" +from datetime import timedelta import logging from typing import Any @@ -17,12 +18,14 @@ from homeassistant.components.water_heater import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT +SCAN_INTERVAL = timedelta(hours=1) + _LOGGER = logging.getLogger(__name__) ECONET_STATE_TO_HA = { @@ -52,6 +55,7 @@ async def async_setup_entry( EcoNetWaterHeater(water_heater) for water_heater in equipment[EquipmentType.WATER_HEATER] ], + update_before_add=True, ) @@ -64,18 +68,8 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): def __init__(self, water_heater): """Initialize.""" super().__init__(water_heater) - self._running = water_heater.running self.water_heater = water_heater - @callback - def on_update_received(self): - """Update was pushed from the econet API.""" - if self._running != self.water_heater.running: - # Water heater running state has changed so check usage on next update - self._attr_should_poll = True - self._running = self.water_heater.running - self.async_write_ha_state() - @property def is_away_mode_on(self): """Return true if away mode is on.""" @@ -153,8 +147,6 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): """Get the latest energy usage.""" await self.water_heater.get_energy_usage() await self.water_heater.get_water_usage() - self.async_write_ha_state() - self._attr_should_poll = False def turn_away_mode_on(self) -> None: """Turn away mode on.""" From 855c2da64ec06647790fb2e5396e20cbdad40bf5 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 27 Nov 2023 12:15:55 +0100 Subject: [PATCH 784/982] Bump `gios` to version 3.2.2 (#104582) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 18ea52fc15f..2e33bc6741e 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==3.2.1"] + "requirements": ["gios==3.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 45cd6999f2d..ab9ad78eb4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.2.1 +gios==3.2.2 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a70c14781a8..6e96559f180 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -710,7 +710,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.2.1 +gios==3.2.2 # homeassistant.components.glances glances-api==0.4.3 From a5fd479608b4ede52af0a2212af1ad6581053b7e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Nov 2023 12:30:51 +0100 Subject: [PATCH 785/982] Bump sfrbox-api to 0.0.8 (#104580) --- .../components/sfr_box/diagnostics.py | 20 +++++++++++++++---- .../components/sfr_box/manifest.json | 2 +- homeassistant/components/sfr_box/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 1fb98053267..e0e84a7ec1a 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -27,16 +27,28 @@ async def async_get_config_entry_diagnostics( }, "data": { "dsl": async_redact_data( - dataclasses.asdict(await data.system.box.dsl_get_info()), TO_REDACT + dataclasses.asdict( + await data.system.box.dsl_get_info() # type:ignore [call-overload] + ), + TO_REDACT, ), "ftth": async_redact_data( - dataclasses.asdict(await data.system.box.ftth_get_info()), TO_REDACT + dataclasses.asdict( + await data.system.box.ftth_get_info() # type:ignore [call-overload] + ), + TO_REDACT, ), "system": async_redact_data( - dataclasses.asdict(await data.system.box.system_get_info()), TO_REDACT + dataclasses.asdict( + await data.system.box.system_get_info() # type:ignore [call-overload] + ), + TO_REDACT, ), "wan": async_redact_data( - dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT + dataclasses.asdict( + await data.system.box.wan_get_info() # type:ignore [call-overload] + ), + TO_REDACT, ), }, } diff --git a/homeassistant/components/sfr_box/manifest.json b/homeassistant/components/sfr_box/manifest.json index eb3c9cb1b68..bf4d91a50f1 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sfr_box", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["sfrbox-api==0.0.6"] + "requirements": ["sfrbox-api==0.0.8"] } diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 1c4540b1c74..f56a9765618 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -188,7 +188,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda x: x.temperature / 1000, + value_fn=lambda x: None if x.temperature is None else x.temperature / 1000, ), ) WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( diff --git a/requirements_all.txt b/requirements_all.txt index ab9ad78eb4c..4ec59bce2e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2436,7 +2436,7 @@ sensorpush-ble==1.5.5 sentry-sdk==1.37.1 # homeassistant.components.sfr_box -sfrbox-api==0.0.6 +sfrbox-api==0.0.8 # homeassistant.components.sharkiq sharkiq==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e96559f180..3203a8e87ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1815,7 +1815,7 @@ sensorpush-ble==1.5.5 sentry-sdk==1.37.1 # homeassistant.components.sfr_box -sfrbox-api==0.0.6 +sfrbox-api==0.0.8 # homeassistant.components.sharkiq sharkiq==1.0.2 From ba8e2ed7d671fa45d6d48b02c8e5190f75e3ecbc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Nov 2023 13:13:02 +0100 Subject: [PATCH 786/982] Improve picnic typing (#104587) --- homeassistant/components/picnic/sensor.py | 10 ++++------ homeassistant/components/picnic/services.py | 5 ++++- homeassistant/components/picnic/todo.py | 12 +++++------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index e7a69e0bf02..507ab82e8e2 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -17,10 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( @@ -44,6 +41,7 @@ from .const import ( SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, SENSOR_SELECTED_SLOT_START, ) +from .coordinator import PicnicUpdateCoordinator @dataclass @@ -237,7 +235,7 @@ async def async_setup_entry( ) -class PicnicSensor(SensorEntity, CoordinatorEntity): +class PicnicSensor(SensorEntity, CoordinatorEntity[PicnicUpdateCoordinator]): """The CoordinatorEntity subclass representing Picnic sensors.""" _attr_has_entity_name = True @@ -246,7 +244,7 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Any], + coordinator: PicnicUpdateCoordinator, config_entry: ConfigEntry, description: PicnicSensorEntityDescription, ) -> None: diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index fa00037462d..b44d4dd5a62 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -77,8 +77,11 @@ async def handle_add_product( ) -def product_search(api_client: PicnicAPI, product_name: str) -> None | str: +def product_search(api_client: PicnicAPI, product_name: str | None) -> None | str: """Query the api client for the product name.""" + if product_name is None: + return None + search_result = api_client.search(product_name) if not search_result or "items" not in search_result[0]: diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index 389909ca06e..47b9685c9ec 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import cast from homeassistant.components.todo import ( TodoItem, @@ -14,12 +14,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COORDINATOR, DOMAIN +from .coordinator import PicnicUpdateCoordinator from .services import product_search _LOGGER = logging.getLogger(__name__) @@ -36,7 +34,7 @@ async def async_setup_entry( async_add_entities([PicnicCart(hass, picnic_coordinator, config_entry)]) -class PicnicCart(TodoListEntity, CoordinatorEntity): +class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): """A Picnic Shopping Cart TodoListEntity.""" _attr_has_entity_name = True @@ -47,7 +45,7 @@ class PicnicCart(TodoListEntity, CoordinatorEntity): def __init__( self, hass: HomeAssistant, - coordinator: DataUpdateCoordinator[Any], + coordinator: PicnicUpdateCoordinator, config_entry: ConfigEntry, ) -> None: """Initialize PicnicCart.""" From 5550dcbec89933f7901779862c3313b6751f7080 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 27 Nov 2023 13:59:25 +0100 Subject: [PATCH 787/982] Add textual representation entities for Fronius status codes (#94155) * optionally decouple `EntityDescription.key` from API response key this makes it possible to have multiple entities for a single API response field * Add optional `value_fn` to EntityDescriptions eg. to be able to map a API response value to a different value (status_code -> message) * Add inverter `status_message` entity * Add meter `meter_location_description` entity * add external battery state * Make Ohmpilot entity state translateable * use built-in StrEnum * test coverage * remove unnecessary checks None is handled before --- homeassistant/components/fronius/const.py | 96 ++++++++++++++++ .../components/fronius/coordinator.py | 46 +++++--- homeassistant/components/fronius/sensor.py | 108 +++++++++++------- homeassistant/components/fronius/strings.json | 35 +++++- tests/components/fronius/test_sensor.py | 97 ++++++++++++---- 5 files changed, 303 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index 4060731b21c..18f35de8336 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -1,7 +1,9 @@ """Constants for the Fronius integration.""" +from enum import StrEnum from typing import Final, NamedTuple, TypedDict from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.typing import StateType DOMAIN: Final = "fronius" @@ -25,3 +27,97 @@ class FroniusDeviceInfo(NamedTuple): device_info: DeviceInfo solar_net_id: SolarNetId unique_id: str + + +class InverterStatusCodeOption(StrEnum): + """Status codes for Fronius inverters.""" + + # these are keys for state translations - so snake_case is used + STARTUP = "startup" + RUNNING = "running" + STANDBY = "standby" + BOOTLOADING = "bootloading" + ERROR = "error" + IDLE = "idle" + READY = "ready" + SLEEPING = "sleeping" + UNKNOWN = "unknown" + INVALID = "invalid" + + +_INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = { + 0: InverterStatusCodeOption.STARTUP, + 1: InverterStatusCodeOption.STARTUP, + 2: InverterStatusCodeOption.STARTUP, + 3: InverterStatusCodeOption.STARTUP, + 4: InverterStatusCodeOption.STARTUP, + 5: InverterStatusCodeOption.STARTUP, + 6: InverterStatusCodeOption.STARTUP, + 7: InverterStatusCodeOption.RUNNING, + 8: InverterStatusCodeOption.STANDBY, + 9: InverterStatusCodeOption.BOOTLOADING, + 10: InverterStatusCodeOption.ERROR, + 11: InverterStatusCodeOption.IDLE, + 12: InverterStatusCodeOption.READY, + 13: InverterStatusCodeOption.SLEEPING, + 255: InverterStatusCodeOption.UNKNOWN, +} + + +def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption: + """Return a status message for a given status code.""" + return _INVERTER_STATUS_CODES.get(code, InverterStatusCodeOption.INVALID) # type: ignore[arg-type] + + +class MeterLocationCodeOption(StrEnum): + """Meter location codes for Fronius meters.""" + + # these are keys for state translations - so snake_case is used + FEED_IN = "feed_in" + CONSUMPTION_PATH = "consumption_path" + GENERATOR = "external_generator" + EXT_BATTERY = "external_battery" + SUBLOAD = "subload" + + +def get_meter_location_description(code: StateType) -> MeterLocationCodeOption | None: + """Return a location_description for a given location code.""" + match int(code): # type: ignore[arg-type] + case 0: + return MeterLocationCodeOption.FEED_IN + case 1: + return MeterLocationCodeOption.CONSUMPTION_PATH + case 3: + return MeterLocationCodeOption.GENERATOR + case 4: + return MeterLocationCodeOption.EXT_BATTERY + case _ as _code if 256 <= _code <= 511: + return MeterLocationCodeOption.SUBLOAD + return None + + +class OhmPilotStateCodeOption(StrEnum): + """OhmPilot state codes for Fronius inverters.""" + + # these are keys for state translations - so snake_case is used + UP_AND_RUNNING = "up_and_running" + KEEP_MINIMUM_TEMPERATURE = "keep_minimum_temperature" + LEGIONELLA_PROTECTION = "legionella_protection" + CRITICAL_FAULT = "critical_fault" + FAULT = "fault" + BOOST_MODE = "boost_mode" + + +_OHMPILOT_STATE_CODES: Final[dict[int, OhmPilotStateCodeOption]] = { + 0: OhmPilotStateCodeOption.UP_AND_RUNNING, + 1: OhmPilotStateCodeOption.KEEP_MINIMUM_TEMPERATURE, + 2: OhmPilotStateCodeOption.LEGIONELLA_PROTECTION, + 3: OhmPilotStateCodeOption.CRITICAL_FAULT, + 4: OhmPilotStateCodeOption.FAULT, + 5: OhmPilotStateCodeOption.BOOST_MODE, +} + + +def get_ohmpilot_state_message(code: StateType) -> OhmPilotStateCodeOption | None: + """Return a status message for a given status code.""" + return _OHMPILOT_STATE_CODES.get(code) # type: ignore[arg-type] diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 94fd5f256aa..fcf9ce0a389 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -49,8 +49,10 @@ class FroniusCoordinatorBase( """Set up the FroniusCoordinatorBase class.""" self._failed_update_count = 0 self.solar_net = solar_net - # unregistered_keys are used to create entities in platform module - self.unregistered_keys: dict[SolarNetId, set[str]] = {} + # unregistered_descriptors are used to create entities in platform module + self.unregistered_descriptors: dict[ + SolarNetId, list[FroniusSensorEntityDescription] + ] = {} super().__init__(*args, update_interval=self.default_interval, **kwargs) @abstractmethod @@ -73,11 +75,11 @@ class FroniusCoordinatorBase( self.update_interval = self.default_interval for solar_net_id in data: - if solar_net_id not in self.unregistered_keys: + if solar_net_id not in self.unregistered_descriptors: # id seen for the first time - self.unregistered_keys[solar_net_id] = { - desc.key for desc in self.valid_descriptions - } + self.unregistered_descriptors[ + solar_net_id + ] = self.valid_descriptions.copy() return data @callback @@ -92,22 +94,34 @@ class FroniusCoordinatorBase( """ @callback - def _add_entities_for_unregistered_keys() -> None: + def _add_entities_for_unregistered_descriptors() -> None: """Add entities for keys seen for the first time.""" - new_entities: list = [] + new_entities: list[_FroniusEntityT] = [] for solar_net_id, device_data in self.data.items(): - for key in self.unregistered_keys[solar_net_id].intersection( - device_data - ): - if device_data[key]["value"] is None: + remaining_unregistered_descriptors = [] + for description in self.unregistered_descriptors[solar_net_id]: + key = description.response_key or description.key + if key not in device_data: + remaining_unregistered_descriptors.append(description) continue - new_entities.append(entity_constructor(self, key, solar_net_id)) - self.unregistered_keys[solar_net_id].remove(key) + if device_data[key]["value"] is None: + remaining_unregistered_descriptors.append(description) + continue + new_entities.append( + entity_constructor( + coordinator=self, + description=description, + solar_net_id=solar_net_id, + ) + ) + self.unregistered_descriptors[ + solar_net_id + ] = remaining_unregistered_descriptors async_add_entities(new_entities) - _add_entities_for_unregistered_keys() + _add_entities_for_unregistered_descriptors() self.solar_net.cleanup_callbacks.append( - self.async_add_listener(_add_entities_for_unregistered_keys) + self.async_add_listener(_add_entities_for_unregistered_descriptors) ) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index f11855ce7e2..f058a25a044 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,6 +1,7 @@ """Support for Fronius devices.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final @@ -30,7 +31,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SOLAR_NET_DISCOVERY_NEW +from .const import ( + DOMAIN, + SOLAR_NET_DISCOVERY_NEW, + InverterStatusCodeOption, + MeterLocationCodeOption, + OhmPilotStateCodeOption, + get_inverter_status_message, + get_meter_location_description, + get_ohmpilot_state_message, +) if TYPE_CHECKING: from . import FroniusSolarNet @@ -102,6 +112,8 @@ class FroniusSensorEntityDescription(SensorEntityDescription): # Gen24 devices may report 0 for total energy while doing firmware updates. # Handling such values shall mitigate spikes in delta calculations. invalid_when_falsy: bool = False + response_key: str | None = None + value_fn: Callable[[StateType], StateType] | None = None INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ @@ -198,6 +210,15 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="status_code", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + FroniusSensorEntityDescription( + key="status_message", + response_key="status_code", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in InverterStatusCodeOption], + value_fn=get_inverter_status_message, ), FroniusSensorEntityDescription( key="led_state", @@ -306,6 +327,15 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="meter_location", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=int, # type: ignore[arg-type] + ), + FroniusSensorEntityDescription( + key="meter_location_description", + response_key="meter_location", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in MeterLocationCodeOption], + value_fn=get_meter_location_description, ), FroniusSensorEntityDescription( key="power_apparent_phase_1", @@ -495,7 +525,11 @@ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="state_message", + response_key="state_code", entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in OhmPilotStateCodeOption], + value_fn=get_ohmpilot_state_message, ), ] @@ -630,24 +664,22 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn """Defines a Fronius coordinator entity.""" entity_description: FroniusSensorEntityDescription - entity_descriptions: list[FroniusSensorEntityDescription] _attr_has_entity_name = True def __init__( self, coordinator: FroniusCoordinatorBase, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" super().__init__(coordinator) - self.entity_description = next( - desc for desc in self.entity_descriptions if desc.key == key - ) + self.entity_description = description + self.response_key = description.response_key or description.key self.solar_net_id = solar_net_id self._attr_native_value = self._get_entity_value() - self._attr_translation_key = self.entity_description.key + self._attr_translation_key = description.key def _device_data(self) -> dict[str, Any]: """Extract information for SolarNet device from coordinator data.""" @@ -655,13 +687,13 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn def _get_entity_value(self) -> Any: """Extract entity value from coordinator. Raises KeyError if not included in latest update.""" - new_value = self.coordinator.data[self.solar_net_id][ - self.entity_description.key - ]["value"] + new_value = self.coordinator.data[self.solar_net_id][self.response_key]["value"] if new_value is None: return self.entity_description.default_value if self.entity_description.invalid_when_falsy and not new_value: return None + if self.entity_description.value_fn is not None: + return self.entity_description.value_fn(new_value) if isinstance(new_value, float): return round(new_value, 4) return new_value @@ -681,54 +713,54 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn class InverterSensor(_FroniusSensorEntity): """Defines a Fronius inverter device sensor entity.""" - entity_descriptions = INVERTER_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusInverterUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius inverter sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) # device_info created in __init__ from a `GetInverterInfo` request self._attr_device_info = coordinator.inverter_info.device_info - self._attr_unique_id = f"{coordinator.inverter_info.unique_id}-{key}" + self._attr_unique_id = ( + f"{coordinator.inverter_info.unique_id}-{description.key}" + ) class LoggerSensor(_FroniusSensorEntity): """Defines a Fronius logger device sensor entity.""" - entity_descriptions = LOGGER_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusLoggerUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) logger_data = self._device_data() # Logger device is already created in FroniusSolarNet._create_solar_net_device self._attr_device_info = coordinator.solar_net.system_device_info - self._attr_native_unit_of_measurement = logger_data[key].get("unit") - self._attr_unique_id = f'{logger_data["unique_identifier"]["value"]}-{key}' + self._attr_native_unit_of_measurement = logger_data[self.response_key].get( + "unit" + ) + self._attr_unique_id = ( + f'{logger_data["unique_identifier"]["value"]}-{description.key}' + ) class MeterSensor(_FroniusSensorEntity): """Defines a Fronius meter device sensor entity.""" - entity_descriptions = METER_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusMeterUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) meter_data = self._device_data() # S0 meters connected directly to inverters respond "n.a." as serial number # `model` contains the inverter id: "S0 Meter at inverter 1" @@ -745,22 +777,20 @@ class MeterSensor(_FroniusSensorEntity): name=meter_data["model"]["value"], via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), ) - self._attr_unique_id = f"{meter_uid}-{key}" + self._attr_unique_id = f"{meter_uid}-{description.key}" class OhmpilotSensor(_FroniusSensorEntity): """Defines a Fronius Ohmpilot sensor entity.""" - entity_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusOhmpilotUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) device_data = self._device_data() self._attr_device_info = DeviceInfo( @@ -771,45 +801,41 @@ class OhmpilotSensor(_FroniusSensorEntity): sw_version=device_data["software"]["value"], via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), ) - self._attr_unique_id = f'{device_data["serial"]["value"]}-{key}' + self._attr_unique_id = f'{device_data["serial"]["value"]}-{description.key}' class PowerFlowSensor(_FroniusSensorEntity): """Defines a Fronius power flow sensor entity.""" - entity_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusPowerFlowUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius power flow sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) # SolarNet device is already created in FroniusSolarNet._create_solar_net_device self._attr_device_info = coordinator.solar_net.system_device_info self._attr_unique_id = ( - f"{coordinator.solar_net.solar_net_device_id}-power_flow-{key}" + f"{coordinator.solar_net.solar_net_device_id}-power_flow-{description.key}" ) class StorageSensor(_FroniusSensorEntity): """Defines a Fronius storage device sensor entity.""" - entity_descriptions = STORAGE_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusStorageUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius storage sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) storage_data = self._device_data() - self._attr_unique_id = f'{storage_data["serial"]["value"]}-{key}' + self._attr_unique_id = f'{storage_data["serial"]["value"]}-{description.key}' self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, storage_data["serial"]["value"])}, manufacturer=storage_data["manufacturer"]["value"], diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 4a0f96ed8e6..de066704644 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -66,6 +66,21 @@ "status_code": { "name": "Status code" }, + "status_message": { + "name": "Status message", + "state": { + "startup": "Startup", + "running": "Running", + "standby": "Standby", + "bootloading": "Bootloading", + "error": "Error", + "idle": "Idle", + "ready": "Ready", + "sleeping": "Sleeping", + "unknown": "Unknown", + "invalid": "Invalid" + } + }, "led_state": { "name": "LED state" }, @@ -114,6 +129,16 @@ "meter_location": { "name": "Meter location" }, + "meter_location_description": { + "name": "Meter location description", + "state": { + "feed_in": "Grid interconnection point", + "consumption_path": "Consumption path", + "external_generator": "External generator", + "external_battery": "External battery", + "subload": "Subload" + } + }, "power_apparent_phase_1": { "name": "Apparent power phase 1" }, @@ -193,7 +218,15 @@ "name": "State code" }, "state_message": { - "name": "State message" + "name": "State message", + "state": { + "up_and_running": "Up and running", + "keep_minimum_temperature": "Keep minimum temperature", + "legionella_protection": "Legionella protection", + "critical_fault": "Critical fault", + "fault": "Fault", + "boost_mode": "Boost mode" + } }, "meter_mode": { "name": "Meter mode" diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index f94b0f3a55c..684e9a3ae5f 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the Fronius sensor platform.""" - from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.coordinator import ( @@ -33,33 +33,34 @@ async def test_symo_inverter( mock_responses(aioclient_mock, night=True) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 21 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 assert_state("sensor.symo_20_dc_current", 0) assert_state("sensor.symo_20_energy_day", 10828) assert_state("sensor.symo_20_total_energy", 44186900) assert_state("sensor.symo_20_energy_year", 25507686) assert_state("sensor.symo_20_dc_voltage", 16) + assert_state("sensor.symo_20_status_message", "startup") # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) freezer.tick(FroniusInverterUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 # 4 additional AC entities assert_state("sensor.symo_20_dc_current", 2.19) assert_state("sensor.symo_20_energy_day", 1113) @@ -70,6 +71,7 @@ async def test_symo_inverter( assert_state("sensor.symo_20_frequency", 49.94) assert_state("sensor.symo_20_ac_power", 1190) assert_state("sensor.symo_20_ac_voltage", 227.90) + assert_state("sensor.symo_20_status_message", "running") # Third test at nighttime - additional AC entities default to 0 mock_responses(aioclient_mock, night=True) @@ -94,7 +96,7 @@ async def test_symo_logger( mock_responses(aioclient_mock) await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 # states are rounded to 4 decimals assert_state("sensor.solarnet_grid_export_tariff", 0.078) assert_state("sensor.solarnet_co2_factor", 0.53) @@ -116,14 +118,14 @@ async def test_symo_meter( mock_responses(aioclient_mock) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 # states are rounded to 4 decimals assert_state("sensor.smart_meter_63a_current_phase_1", 7.755) assert_state("sensor.smart_meter_63a_current_phase_2", 6.68) @@ -157,6 +159,50 @@ async def test_symo_meter( assert_state("sensor.smart_meter_63a_voltage_phase_1_2", 395.9) assert_state("sensor.smart_meter_63a_voltage_phase_2_3", 398) assert_state("sensor.smart_meter_63a_voltage_phase_3_1", 398) + assert_state("sensor.smart_meter_63a_meter_location", 0) + assert_state("sensor.smart_meter_63a_meter_location_description", "feed_in") + + +@pytest.mark.parametrize( + ("location_code", "expected_code", "expected_description"), + [ + (-1, -1, "unknown"), + (3, 3, "external_generator"), + (4, 4, "external_battery"), + (7, 7, "unknown"), + (256, 256, "subload"), + (511, 511, "subload"), + (512, 512, "unknown"), + ], +) +async def test_symo_meter_forged( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + location_code: int | None, + expected_code: int | str, + expected_description: str, +) -> None: + """Tests for meter location codes we have no fixture for.""" + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state + assert state.state == str(expected_state) + + mock_responses( + aioclient_mock, + fixture_set="symo", + override_data={ + "symo/GetMeterRealtimeData.json": [ + (["Body", "Data", "0", "Meter_Location_Current"], location_code), + ], + }, + ) + await setup_fronius_integration(hass) + assert_state("sensor.smart_meter_63a_meter_location", expected_code) + assert_state( + "sensor.smart_meter_63a_meter_location_description", expected_description + ) async def test_symo_power_flow( @@ -175,14 +221,14 @@ async def test_symo_power_flow( mock_responses(aioclient_mock, night=True) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 21 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 # states are rounded to 4 decimals assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_total_energy", 44186900) @@ -197,7 +243,7 @@ async def test_symo_power_flow( async_fire_time_changed(hass) await hass.async_block_till_done() # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 assert_state("sensor.solarnet_energy_day", 1101.7001) assert_state("sensor.solarnet_total_energy", 44188000) assert_state("sensor.solarnet_energy_year", 25508788) @@ -212,7 +258,7 @@ async def test_symo_power_flow( freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_total_energy", 44186900) assert_state("sensor.solarnet_energy_year", 25507686) @@ -238,18 +284,19 @@ async def test_gen24( mock_responses(aioclient_mock, fixture_set="gen24") config_entry = await setup_fronius_integration(hass, is_logger=False) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 23 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 # inverter 1 assert_state("sensor.inverter_name_ac_current", 0.1589) assert_state("sensor.inverter_name_dc_current_2", 0.0754) assert_state("sensor.inverter_name_status_code", 7) + assert_state("sensor.inverter_name_status_message", "running") assert_state("sensor.inverter_name_dc_current", 0.0783) assert_state("sensor.inverter_name_dc_voltage_2", 403.4312) assert_state("sensor.inverter_name_ac_power", 37.3204) @@ -264,7 +311,8 @@ async def test_gen24( assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 2013105.0) assert_state("sensor.smart_meter_ts_65a_3_real_power", 653.1) assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9) - assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0.0) + assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0) + assert_state("sensor.smart_meter_ts_65a_3_meter_location_description", "feed_in") assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.828) assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_consumed", 88221.0) assert_state("sensor.smart_meter_ts_65a_3_real_energy_minus", 3863340.0) @@ -336,14 +384,14 @@ async def test_gen24_storage( hass, is_logger=False, unique_id="12345678" ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 34 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 35 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 66 # inverter 1 assert_state("sensor.gen24_storage_dc_current", 0.3952) assert_state("sensor.gen24_storage_dc_voltage_2", 318.8103) @@ -352,6 +400,7 @@ async def test_gen24_storage( assert_state("sensor.gen24_storage_ac_power", 250.9093) assert_state("sensor.gen24_storage_error_code", 0) assert_state("sensor.gen24_storage_status_code", 7) + assert_state("sensor.gen24_storage_status_message", "running") assert_state("sensor.gen24_storage_total_energy", 7512794.0117) assert_state("sensor.gen24_storage_inverter_state", "Running") assert_state("sensor.gen24_storage_dc_voltage", 419.1009) @@ -363,7 +412,8 @@ async def test_gen24_storage( assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.698) assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 1247204.0) assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9) - assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0.0) + assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0) + assert_state("sensor.smart_meter_ts_65a_3_meter_location_description", "feed_in") assert_state("sensor.smart_meter_ts_65a_3_reactive_power", -501.5) assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_produced", 3266105.0) assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_3", 19.6) @@ -396,7 +446,7 @@ async def test_gen24_storage( assert_state("sensor.ohmpilot_power", 0.0) assert_state("sensor.ohmpilot_temperature", 38.9) assert_state("sensor.ohmpilot_state_code", 0.0) - assert_state("sensor.ohmpilot_state_message", "Up and running") + assert_state("sensor.ohmpilot_state_message", "up_and_running") # power_flow assert_state("sensor.solarnet_power_grid", 2274.9) assert_state("sensor.solarnet_power_battery", 0.1591) @@ -463,14 +513,14 @@ async def test_primo_s0( mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2]) config_entry = await setup_fronius_integration(hass, is_logger=True) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 29 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 30 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 40 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 43 # logger assert_state("sensor.solarnet_grid_export_tariff", 1) assert_state("sensor.solarnet_co2_factor", 0.53) @@ -483,6 +533,7 @@ async def test_primo_s0( assert_state("sensor.primo_5_0_1_error_code", 0) assert_state("sensor.primo_5_0_1_dc_current", 4.23) assert_state("sensor.primo_5_0_1_status_code", 7) + assert_state("sensor.primo_5_0_1_status_message", "running") assert_state("sensor.primo_5_0_1_energy_year", 7532755.5) assert_state("sensor.primo_5_0_1_ac_current", 3.85) assert_state("sensor.primo_5_0_1_ac_voltage", 223.9) @@ -497,6 +548,7 @@ async def test_primo_s0( assert_state("sensor.primo_3_0_1_error_code", 0) assert_state("sensor.primo_3_0_1_dc_current", 0.97) assert_state("sensor.primo_3_0_1_status_code", 7) + assert_state("sensor.primo_3_0_1_status_message", "running") assert_state("sensor.primo_3_0_1_energy_year", 3596193.25) assert_state("sensor.primo_3_0_1_ac_current", 1.32) assert_state("sensor.primo_3_0_1_ac_voltage", 223.6) @@ -505,6 +557,9 @@ async def test_primo_s0( assert_state("sensor.primo_3_0_1_led_state", 0) # meter assert_state("sensor.s0_meter_at_inverter_1_meter_location", 1) + assert_state( + "sensor.s0_meter_at_inverter_1_meter_location_description", "consumption_path" + ) assert_state("sensor.s0_meter_at_inverter_1_real_power", -2216.7487) # power_flow assert_state("sensor.solarnet_power_load", -2218.9349) From cf9b0e804f5c446213c8f6de4165c574aef5bd24 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 27 Nov 2023 14:16:18 +0100 Subject: [PATCH 788/982] Deprecate legacy api auth provider (#104409) Co-authored-by: Franck Nijhof --- .../auth/providers/legacy_api_password.py | 23 +++++++++- homeassistant/components/auth/strings.json | 6 +++ script/hassfest/translations.py | 46 +++++++++++-------- .../providers/test_legacy_api_password.py | 22 +++++++-- 4 files changed, 72 insertions(+), 25 deletions(-) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 0cadbf07589..98c246d74e4 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -10,10 +10,11 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import async_get_hass, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from ..models import Credentials, UserMeta from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow @@ -21,10 +22,28 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow AUTH_PROVIDER_TYPE = "legacy_api_password" CONF_API_PASSWORD = "api_password" -CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( +_CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( {vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA ) + +def _create_repair_and_validate(config: dict[str, Any]) -> dict[str, Any]: + async_create_issue( + async_get_hass(), + "auth", + "deprecated_legacy_api_password", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_legacy_api_password", + ) + + return _CONFIG_SCHEMA(config) # type: ignore[no-any-return] + + +CONFIG_SCHEMA = _create_repair_and_validate + + LEGACY_USER_NAME = "Legacy API password user" diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index d386bb7a488..0dd3ee64cdf 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -31,5 +31,11 @@ "invalid_code": "Invalid code, please try again." } } + }, + "issues": { + "deprecated_legacy_api_password": { + "title": "The legacy API password is deprecated", + "description": "The legacy API password authentication provider is deprecated and will be removed. Please remove it from your YAML configuration and use the default Home Assistant authentication provider instead." + } } } diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 950eeb827ba..fa2956dd47d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -215,6 +215,29 @@ def gen_data_entry_schema( return vol.All(*validators) +def gen_issues_schema(config: Config, integration: Integration) -> dict[str, Any]: + """Generate the issues schema.""" + return { + str: vol.All( + cv.has_at_least_one_key("description", "fix_flow"), + vol.Schema( + { + vol.Required("title"): translation_value_validator, + vol.Exclusive( + "description", "fixable" + ): translation_value_validator, + vol.Exclusive("fix_flow", "fixable"): gen_data_entry_schema( + config=config, + integration=integration, + flow_title=UNDEFINED, + require_step_title=False, + ), + }, + ), + ) + } + + def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: """Generate a strings schema.""" return vol.Schema( @@ -266,25 +289,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("application_credentials"): { vol.Optional("description"): translation_value_validator, }, - vol.Optional("issues"): { - str: vol.All( - cv.has_at_least_one_key("description", "fix_flow"), - vol.Schema( - { - vol.Required("title"): translation_value_validator, - vol.Exclusive( - "description", "fixable" - ): translation_value_validator, - vol.Exclusive("fix_flow", "fixable"): gen_data_entry_schema( - config=config, - integration=integration, - flow_title=UNDEFINED, - require_step_title=False, - ), - }, - ), - ) - }, + vol.Optional("issues"): gen_issues_schema(config, integration), vol.Optional("entity_component"): cv.schema_with_slug_keys( { vol.Optional("name"): str, @@ -362,7 +367,8 @@ def gen_auth_schema(config: Config, integration: Integration) -> vol.Schema: flow_title=REQUIRED, require_step_title=True, ) - } + }, + vol.Optional("issues"): gen_issues_schema(config, integration), } ) diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py index 7c2335f7ccc..3d89c577ebf 100644 --- a/tests/auth/providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -5,6 +5,12 @@ from homeassistant import auth, data_entry_flow from homeassistant.auth import auth_store from homeassistant.auth.providers import legacy_api_password from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import ensure_auth_manager_loaded + +CONFIG = {"type": "legacy_api_password", "api_password": "test-password"} @pytest.fixture @@ -16,9 +22,7 @@ def store(hass): @pytest.fixture def provider(hass, store): """Mock provider.""" - return legacy_api_password.LegacyApiPasswordAuthProvider( - hass, store, {"type": "legacy_api_password", "api_password": "test-password"} - ) + return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, CONFIG) @pytest.fixture @@ -68,3 +72,15 @@ async def test_login_flow_works(hass: HomeAssistant, manager) -> None: flow_id=result["flow_id"], user_input={"password": "test-password"} ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_create_repair_issue(hass: HomeAssistant): + """Test legacy api password auth provider creates a reapir issue.""" + hass.auth = await auth.auth_manager_from_config(hass, [CONFIG], []) + ensure_auth_manager_loaded(hass.auth) + await async_setup_component(hass, "auth", {}) + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + assert issue_registry.async_get_issue( + domain="auth", issue_id="deprecated_legacy_api_password" + ) From 706add4a57120a93d7b7fe40e722b00d634c76c2 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 27 Nov 2023 15:38:59 +0200 Subject: [PATCH 789/982] Switch formatting from black to ruff-format (#102893) Co-authored-by: Franck Nijhof --- .devcontainer/devcontainer.json | 7 +- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/ci.yaml | 37 ++-------- .pre-commit-config.yaml | 9 +-- .vscode/extensions.json | 6 +- Dockerfile.dev | 3 +- homeassistant/auth/permissions/types.py | 4 +- .../components/assist_pipeline/pipeline.py | 6 +- .../assist_pipeline/websocket_api.py | 3 +- .../components/bmw_connected_drive/select.py | 3 +- homeassistant/components/cloud/http_api.py | 9 ++- .../components/deconz/deconz_device.py | 5 +- .../devolo_home_network/__init__.py | 3 +- .../components/dlna_dmr/media_player.py | 7 +- homeassistant/components/dsmr/sensor.py | 4 +- homeassistant/components/elmax/cover.py | 12 ++-- .../components/esphome/enum_mapper.py | 4 +- homeassistant/components/esphome/fan.py | 3 +- homeassistant/components/evohome/__init__.py | 11 +-- homeassistant/components/goodwe/sensor.py | 12 ++-- .../components/google_assistant/helpers.py | 6 +- homeassistant/components/google_tasks/todo.py | 3 +- homeassistant/components/hdmi_cec/__init__.py | 4 +- homeassistant/components/homekit/type_fans.py | 9 ++- .../hunterdouglas_powerview/select.py | 3 +- homeassistant/components/imap/__init__.py | 4 +- homeassistant/components/kraken/sensor.py | 3 +- .../components/landisgyr_heat_meter/sensor.py | 4 +- homeassistant/components/lookin/__init__.py | 3 +- homeassistant/components/matrix/__init__.py | 5 +- homeassistant/components/matter/event.py | 8 ++- .../components/media_player/__init__.py | 3 +- homeassistant/components/mqtt/climate.py | 7 +- homeassistant/components/mqtt/util.py | 5 +- homeassistant/components/nextcloud/sensor.py | 6 +- homeassistant/components/onvif/base.py | 3 +- .../hitachi_air_to_air_heat_pump_hlrrwifi.py | 13 ++-- .../components/private_ble_device/sensor.py | 18 +++-- .../recorder/auto_repairs/schema.py | 5 +- .../components/recorder/db_schema.py | 10 ++- homeassistant/components/recorder/filters.py | 3 +- .../components/recorder/migration.py | 4 +- homeassistant/components/reolink/__init__.py | 6 +- .../components/rfxtrx/config_flow.py | 7 +- .../components/sonos/media_player.py | 6 +- homeassistant/components/stream/recorder.py | 2 + .../components/synology_dsm/camera.py | 4 +- homeassistant/components/template/weather.py | 3 +- homeassistant/components/velbus/__init__.py | 8 ++- homeassistant/components/vesync/sensor.py | 12 ++-- .../components/vodafone_station/sensor.py | 6 +- homeassistant/components/voip/voip.py | 12 ++-- .../yamaha_musiccast/config_flow.py | 4 +- .../components/zwave_js/binary_sensor.py | 4 +- homeassistant/components/zwave_js/update.py | 3 +- homeassistant/helpers/event.py | 10 ++- homeassistant/helpers/restore_state.py | 3 +- homeassistant/helpers/update_coordinator.py | 3 +- homeassistant/loader.py | 8 +-- homeassistant/util/json.py | 9 ++- homeassistant/util/location.py | 6 +- homeassistant/util/yaml/loader.py | 7 +- pyproject.toml | 3 - requirements_test_pre_commit.txt | 3 +- script/check_format | 6 +- script/gen_requirements_all.py | 4 +- script/hassfest/serializer.py | 14 ++-- tests/common.py | 2 +- tests/components/airvisual_pro/conftest.py | 4 +- tests/components/analytics/test_analytics.py | 12 ++-- tests/components/anova/__init__.py | 2 +- tests/components/backup/test_manager.py | 3 +- tests/components/blink/test_config_flow.py | 9 ++- tests/components/bluetooth/conftest.py | 30 ++++---- tests/components/bond/common.py | 16 ++--- tests/components/bond/test_init.py | 12 +--- tests/components/cast/test_config_flow.py | 2 +- tests/components/comelit/test_config_flow.py | 8 +-- tests/components/config/test_automation.py | 4 +- tests/components/denonavr/test_config_flow.py | 3 +- tests/components/dhcp/test_init.py | 7 +- tests/components/ecobee/test_config_flow.py | 4 +- .../electrasmart/test_config_flow.py | 3 +- tests/components/elkm1/test_config_flow.py | 4 +- tests/components/enphase_envoy/conftest.py | 3 +- tests/components/epson/test_media_player.py | 2 +- tests/components/esphome/test_update.py | 6 +- tests/components/evil_genius_labs/conftest.py | 3 +- tests/components/fritz/test_config_flow.py | 18 +++-- tests/components/gios/__init__.py | 3 +- tests/components/gios/test_config_flow.py | 6 +- tests/components/gios/test_init.py | 4 +- .../components/google_assistant/test_http.py | 2 +- .../google_assistant_sdk/test_notify.py | 7 +- tests/components/guardian/conftest.py | 5 +- tests/components/hassio/conftest.py | 4 +- tests/components/homekit/conftest.py | 14 ++-- tests/components/homekit/test_homekit.py | 18 ++--- .../homematicip_cloud/test_device.py | 2 +- .../components/homematicip_cloud/test_hap.py | 3 +- tests/components/iaqualink/test_init.py | 6 +- tests/components/insteon/test_init.py | 3 +- tests/components/insteon/test_lock.py | 4 +- tests/components/iqvia/conftest.py | 8 +-- tests/components/knx/test_config_flow.py | 4 +- .../linear_garage_door/test_config_flow.py | 6 +- tests/components/logbook/test_init.py | 10 ++- .../lutron_caseta/test_config_flow.py | 3 +- tests/components/mill/test_init.py | 3 +- tests/components/mysensors/conftest.py | 3 +- tests/components/netatmo/common.py | 2 +- tests/components/netatmo/test_camera.py | 6 +- tests/components/netatmo/test_diagnostics.py | 2 +- tests/components/netatmo/test_init.py | 4 +- tests/components/netatmo/test_light.py | 2 +- tests/components/onboarding/test_views.py | 3 +- .../opentherm_gw/test_config_flow.py | 6 +- tests/components/otbr/test_util.py | 4 +- tests/components/otbr/test_websocket_api.py | 4 +- tests/components/plex/test_config_flow.py | 2 +- tests/components/python_script/test_init.py | 4 +- tests/components/rainbird/test_calendar.py | 3 +- tests/components/rainmachine/conftest.py | 3 +- tests/components/recorder/common.py | 12 +--- .../recorder/test_migration_from_schema_32.py | 12 +--- tests/components/recorder/test_purge.py | 4 +- .../recorder/test_purge_v32_schema.py | 4 +- .../components/recorder/test_v32_migration.py | 16 ++--- .../components/recorder/test_websocket_api.py | 4 +- tests/components/risco/conftest.py | 4 +- tests/components/risco/test_config_flow.py | 6 +- tests/components/roborock/conftest.py | 7 +- tests/components/samsungtv/conftest.py | 4 +- tests/components/sensibo/test_button.py | 2 +- tests/components/sensibo/test_climate.py | 10 +-- tests/components/sensibo/test_select.py | 4 +- tests/components/sensibo/test_switch.py | 4 +- tests/components/simplisafe/conftest.py | 3 +- tests/components/simplisafe/test_init.py | 3 +- tests/components/smappee/test_config_flow.py | 12 +--- tests/components/sonos/conftest.py | 4 +- tests/components/subaru/conftest.py | 4 +- .../components/switchbee/test_config_flow.py | 4 +- .../system_bridge/test_config_flow.py | 6 +- tests/components/upnp/conftest.py | 4 +- tests/components/usb/test_init.py | 72 +++++-------------- tests/components/vilfo/test_config_flow.py | 20 ++---- .../components/vlc_telnet/test_config_flow.py | 6 +- .../vodafone_station/test_config_flow.py | 12 ++-- tests/components/waqi/test_config_flow.py | 6 +- tests/components/watttime/conftest.py | 4 +- tests/components/withings/test_diagnostics.py | 4 +- tests/components/withings/test_init.py | 6 +- tests/components/wyoming/test_tts.py | 2 +- .../yamaha_musiccast/test_config_flow.py | 4 +- tests/components/yeelight/test_config_flow.py | 6 +- .../zwave_js/test_device_trigger.py | 70 ++++++++---------- tests/helpers/test_check_config.py | 4 +- tests/helpers/test_system_info.py | 20 ++---- tests/test_requirements.py | 14 ++-- tests/test_runner.py | 2 +- 161 files changed, 530 insertions(+), 607 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 83ee0a2e422..44a81718e10 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,7 +10,7 @@ "customizations": { "vscode": { "extensions": [ - "ms-python.black-formatter", + "charliermarsh.ruff", "ms-python.pylint", "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", @@ -39,7 +39,10 @@ "!include_dir_list scalar", "!include_dir_merge_list scalar", "!include_dir_merge_named scalar" - ] + ], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } } } } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4bc1442d9e9..d69b1ac0c7d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -60,7 +60,7 @@ - [ ] There is no commented out code in this PR. - [ ] I have followed the [development checklist][dev-checklist] - [ ] I have followed the [perfect PR recommendations][perfect-pr] -- [ ] The code has been formatted using Black (`black --fast homeassistant tests`) +- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`) - [ ] Tests have been added to verify that the new code works. If user exposed functionality or configuration variables are added/changed: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4b99c3ddc04..ba2917042af 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,6 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 6 - BLACK_CACHE_VERSION: 1 HA_SHORT_VERSION: "2023.12" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" @@ -58,7 +57,6 @@ env: POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']" PRE_COMMIT_CACHE: ~/.cache/pre-commit PIP_CACHE: /tmp/pip-cache - BLACK_CACHE: /tmp/black-cache SQLALCHEMY_WARN_20: 1 PYTHONASYNCIODEBUG: 1 HASS_CI: 1 @@ -261,8 +259,8 @@ jobs: . venv/bin/activate pre-commit install-hooks - lint-black: - name: Check black + lint-ruff-format: + name: Check ruff-format runs-on: ubuntu-22.04 needs: - info @@ -276,13 +274,6 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - - name: Generate partial black restore key - id: generate-black-key - run: | - black_version=$(cat requirements_test_pre_commit.txt | grep black | cut -d '=' -f 3) - echo "version=$black_version" >> $GITHUB_OUTPUT - echo "key=black-${{ env.BLACK_CACHE_VERSION }}-$black_version-${{ - env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv uses: actions/cache/restore@v3.3.2 @@ -301,33 +292,17 @@ jobs: key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - - name: Restore black cache - uses: actions/cache@v3.3.2 - with: - path: ${{ env.BLACK_CACHE }} - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - steps.generate-black-key.outputs.key }} - restore-keys: | - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-black-${{ - env.BLACK_CACHE_VERSION }}-${{ steps.generate-black-key.outputs.version }}-${{ - env.HA_SHORT_VERSION }}- - - name: Run black (fully) - if: needs.info.outputs.test_full_suite == 'true' - env: - BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} + - name: Run ruff-format (fully) run: | . venv/bin/activate - pre-commit run --hook-stage manual black --all-files --show-diff-on-failure - - name: Run black (partially) + pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure + - name: Run ruff-format (partially) if: needs.info.outputs.test_full_suite == 'false' shell: bash - env: - BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | . venv/bin/activate shopt -s globstar - pre-commit run --hook-stage manual black --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure + pre-commit run --hook-stage manual ruff-format --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure lint-ruff: name: Check ruff diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d43bcf1b02..ae135f30407 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,11 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.1 + rev: v0.1.6 hooks: - id: ruff args: - --fix - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.11.0 - hooks: - - id: black - args: - - --quiet + - id: ruff-format files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.py$ - repo: https://github.com/codespell-project/codespell rev: v2.2.2 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 951134133e5..8a5d7d486b7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,7 @@ { - "recommendations": ["esbenp.prettier-vscode", "ms-python.python"] + "recommendations": [ + "charliermarsh.ruff", + "esbenp.prettier-vscode", + "ms-python.python" + ] } diff --git a/Dockerfile.dev b/Dockerfile.dev index 857ccfa3997..a1143adde89 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -5,8 +5,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Uninstall pre-installed formatting and linting tools # They would conflict with our pinned versions RUN \ - pipx uninstall black \ - && pipx uninstall pydocstyle \ + pipx uninstall pydocstyle \ && pipx uninstall pycodestyle \ && pipx uninstall mypy \ && pipx uninstall pylint diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py index 0aa8807211a..cf3632d06d5 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -5,9 +5,7 @@ from collections.abc import Mapping ValueType = ( # Example: entities.all = { read: true, control: true } - Mapping[str, bool] - | bool - | None + Mapping[str, bool] | bool | None ) # Example: entities.domains = { light: … } diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index fa7d2115769..1eb32a9dc3f 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1315,9 +1315,9 @@ class PipelineInput: if stt_audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. - async def buffer_then_audio_stream() -> AsyncGenerator[ - ProcessedAudioChunk, None - ]: + async def buffer_then_audio_stream() -> ( + AsyncGenerator[ProcessedAudioChunk, None] + ): # Buffered audio for chunk in stt_audio_buffer: yield chunk diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 6bfe969dc3e..89cced519df 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -417,8 +417,7 @@ async def websocket_device_capture( # single sample (16 bits) per queue item. max_queue_items = ( # +1 for None to signal end - int(math.ceil(timeout_seconds * CAPTURE_RATE)) - + 1 + int(math.ceil(timeout_seconds * CAPTURE_RATE)) + 1 ) audio_queue = DeviceAudioQueue(queue=asyncio.Queue(maxsize=max_queue_items)) diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 3467322a4af..1d8b736f4dd 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -44,7 +44,8 @@ SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { translation_key="ac_limit", is_available=lambda v: v.is_remote_set_ac_limit_enabled, dynamic_options=lambda v: [ - str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + str(lim) + for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] ], current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e3b1b39f687..634a5e20b33 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -140,7 +140,7 @@ def _ws_handle_cloud_errors( handler: Callable[ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], Coroutine[None, None, None], - ] + ], ) -> Callable[ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], Coroutine[None, None, None], @@ -362,8 +362,11 @@ def _require_cloud_login( handler: Callable[ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], None, - ] -) -> Callable[[HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], None,]: + ], +) -> Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + None, +]: """Websocket decorator that requires cloud to be logged in.""" @wraps(handler) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 4c0f35266f9..8a5ced2c678 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -129,9 +129,8 @@ class DeconzDevice(DeconzBase[_DeviceT], Entity): if self.gateway.ignore_state_updates: return - if ( - self._update_keys is not None - and not self._device.changed_keys.intersection(self._update_keys) + if self._update_keys is not None and not self._device.changed_keys.intersection( + self._update_keys ): return diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 0fee65d57b6..842d1bee40f 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -63,7 +63,8 @@ async def async_setup_entry( # noqa: C901 ) await device.async_connect(session_instance=async_client) device.password = entry.data.get( - CONF_PASSWORD, "" # This key was added in HA Core 2022.6 + CONF_PASSWORD, + "", # This key was added in HA Core 2022.6 ) except DeviceNotFound as err: raise ConfigEntryNotReady( diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 3a57ba2c8ce..cd2f1ae2f50 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -453,10 +453,9 @@ class DlnaDmrEntity(MediaPlayerEntity): for state_variable in state_variables: # Force a state refresh when player begins or pauses playback # to update the position info. - if ( - state_variable.name == "TransportState" - and state_variable.value - in (TransportState.PLAYING, TransportState.PAUSED_PLAYBACK) + if state_variable.name == "TransportState" and state_variable.value in ( + TransportState.PLAYING, + TransportState.PAUSED_PLAYBACK, ): force_refresh = True diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 696698cc176..3dbd446001f 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -441,9 +441,7 @@ async def async_setup_entry( description, entry, telegram, - *device_class_and_uom( - telegram, description - ), # type: ignore[arg-type] + *device_class_and_uom(telegram, description), # type: ignore[arg-type] ) for description in all_sensors if ( diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 8a6acb154aa..e05b17b9171 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -18,13 +18,11 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -_COMMAND_BY_MOTION_STATUS = ( - { # Maps the stop command to use for every cover motion status - CoverStatus.DOWN: CoverCommand.DOWN, - CoverStatus.UP: CoverCommand.UP, - CoverStatus.IDLE: None, - } -) +_COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover motion status + CoverStatus.DOWN: CoverCommand.DOWN, + CoverStatus.UP: CoverCommand.UP, + CoverStatus.IDLE: None, +} async def async_setup_entry( diff --git a/homeassistant/components/esphome/enum_mapper.py b/homeassistant/components/esphome/enum_mapper.py index 566f0bc503b..fd09f9a05b6 100644 --- a/homeassistant/components/esphome/enum_mapper.py +++ b/homeassistant/components/esphome/enum_mapper.py @@ -14,9 +14,7 @@ class EsphomeEnumMapper(Generic[_EnumT, _ValT]): def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: """Construct a EsphomeEnumMapper.""" # Add none mapping - augmented_mapping: dict[ - _EnumT | None, _ValT | None - ] = mapping # type: ignore[assignment] + augmented_mapping: dict[_EnumT | None, _ValT | None] = mapping # type: ignore[assignment] augmented_mapping[None] = None self._mapping = augmented_mapping diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index a6ca52d6c1a..9942498e12d 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -117,7 +117,8 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Return the current speed percentage.""" if not self._supports_speed_levels: return ordered_list_item_to_percentage( - ORDERED_NAMED_FAN_SPEEDS, self._state.speed # type: ignore[misc] + ORDERED_NAMED_FAN_SPEEDS, + self._state.speed, # type: ignore[misc] ) return ranged_value_to_percentage( diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index c26310bf61c..f4ceaf2c48c 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -124,10 +124,13 @@ def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: def convert_key(key: str) -> str: """Convert a string to snake_case.""" string = re.sub(r"[\-\.\s]", "_", str(key)) - return (string[0]).lower() + re.sub( - r"[A-Z]", - lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] - string[1:], + return ( + (string[0]).lower() + + re.sub( + r"[A-Z]", + lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] + string[1:], + ) ) return { diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 332280bac5a..0065d70dda9 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -79,12 +79,12 @@ _ICONS: dict[SensorKind, str] = { class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" - value: Callable[ - [GoodweUpdateCoordinator, str], Any - ] = lambda coordinator, sensor: coordinator.sensor_value(sensor) - available: Callable[ - [GoodweUpdateCoordinator], bool - ] = lambda coordinator: coordinator.last_update_success + value: Callable[[GoodweUpdateCoordinator, str], Any] = ( + lambda coordinator, sensor: coordinator.sensor_value(sensor) + ) + available: Callable[[GoodweUpdateCoordinator], bool] = ( + lambda coordinator: coordinator.last_update_success + ) _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 2eeb1903c85..af892f15af4 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -59,7 +59,11 @@ LOCAL_SDK_MIN_VERSION = AwesomeVersion("2.1.5") @callback def _get_registry_entries( hass: HomeAssistant, entity_id: str -) -> tuple[er.RegistryEntry | None, dr.DeviceEntry | None, ar.AreaEntry | None,]: +) -> tuple[ + er.RegistryEntry | None, + dr.DeviceEntry | None, + ar.AreaEntry | None, +]: """Get registry entries.""" ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index e5c90523a18..d3c4dfa6936 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -93,7 +93,8 @@ class GoogleTaskTodoListEntity( summary=item["title"], uid=item["id"], status=TODO_STATUS_MAP.get( - item.get("status"), TodoItemStatus.NEEDS_ACTION # type: ignore[arg-type] + item.get("status"), # type: ignore[arg-type] + TodoItemStatus.NEEDS_ACTION, ), ) for item in _order_tasks(self.coordinator.data) diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 19621e28d03..54ea2f3e5bd 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -195,9 +195,7 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 loop = ( # Create own thread if more than 1 CPU - hass.loop - if multiprocessing.cpu_count() < 2 - else None + hass.loop if multiprocessing.cpu_count() < 2 else None ) host = base_config[DOMAIN].get(CONF_HOST) display_name = base_config[DOMAIN].get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 9b27653e4cf..d371998aaf8 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -124,12 +124,15 @@ class Fan(HomeAccessory): ), ) + setter_callback = ( + lambda value, preset_mode=preset_mode: self.set_preset_mode( + value, preset_mode + ) + ) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( CHAR_ON, value=False, - setter_callback=lambda value, preset_mode=preset_mode: self.set_preset_mode( - value, preset_mode - ), + setter_callback=setter_callback, ) if CHAR_SWING_MODE in self.chars: diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 37d1193e0e5..151b3a58011 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -116,5 +116,6 @@ class PowerViewSelect(ShadeEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self._shade, option) - await self._shade.refresh() # force update data to ensure new info is in coordinator + # force update data to ensure new info is in coordinator + await self._shade.refresh() self.async_write_ha_state() diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 3914e0c52c1..fea2583a27a 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -66,8 +66,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = hass.data[ DOMAIN - ].pop( - entry.entry_id - ) + ].pop(entry.entry_id) await coordinator.shutdown() return unload_ok diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index a6c00e62b62..21eb3f2e5a1 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -259,7 +259,8 @@ class KrakenSensor( return try: self._attr_native_value = self.entity_description.value_fn( - self.coordinator, self.tracked_asset_pair_wsname # type: ignore[arg-type] + self.coordinator, # type: ignore[arg-type] + self.tracked_asset_pair_wsname, ) self._received_data_at_least_once = True except KeyError: diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 8ef81e899b7..d7485e88fb0 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -316,7 +316,9 @@ class HeatMeterSensor( """Set up the sensor with the initial values.""" super().__init__(coordinator) self.key = description.key - self._attr_unique_id = f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] + self._attr_unique_id = ( + f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] + ) self._attr_name = f"Heat Meter {description.name}" self.entity_description = description self._attr_device_info = device diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 7656de8d385..37156e9ca08 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -118,7 +118,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: push_coordinator = LookinPushCoordinator(entry.title) if lookin_device.model >= 2: - meteo_coordinator = LookinDataUpdateCoordinator[MeteoSensor]( + coordinator_class = LookinDataUpdateCoordinator[MeteoSensor] + meteo_coordinator = coordinator_class( hass, push_coordinator, name=entry.title, diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index f9ef3593fe6..ddda50aa8b2 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -348,7 +348,10 @@ class MatrixBot: self._access_tokens[self._mx_id] = token await self.hass.async_add_executor_job( - save_json, self._session_filepath, self._access_tokens, True # private=True + save_json, + self._session_filepath, + self._access_tokens, + True, # private=True ) async def _login(self) -> None: diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 3361c3fa146..e84fcec32d8 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -104,9 +104,11 @@ class MatterEventEntity(MatterEntity, EventEntity): """Call when Node attribute(s) changed.""" @callback - def _on_matter_node_event( - self, event: EventType, data: MatterNodeEvent - ) -> None: # noqa: F821 + def _on_matter_node_event( # noqa: F821 + self, + event: EventType, + data: MatterNodeEvent, + ) -> None: """Call on NodeEvent.""" if data.endpoint_id != self._endpoint.endpoint_id: return diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f3ff925a1a4..50365f90f1f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1137,8 +1137,7 @@ class MediaPlayerImageView(HomeAssistantView): extra_urls = [ # Need to modify the default regex for media_content_id as it may # include arbitrary characters including '/','{', or '}' - url - + "/browse_media/{media_content_type}/{media_content_id:.+}", + url + "/browse_media/{media_content_type}/{media_content_id:.+}", ] def __init__(self, component: EntityComponent[MediaPlayerEntity]) -> None: diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 3fa3ebfd30c..c8696071fb4 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -470,9 +470,10 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): except ValueError: _LOGGER.error("Could not parse %s from %s", template_name, payload) - def prepare_subscribe_topics( - self, topics: dict[str, dict[str, Any]] - ) -> None: # noqa: C901 + def prepare_subscribe_topics( # noqa: C901 + self, + topics: dict[str, dict[str, Any]], + ) -> None: """(Re)Subscribe to topics.""" @callback diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 6e364182cb0..f478ad712d7 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -63,9 +63,8 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: state_reached_future: asyncio.Future[bool] if DATA_MQTT_AVAILABLE not in hass.data: - hass.data[ - DATA_MQTT_AVAILABLE - ] = state_reached_future = hass.loop.create_future() + state_reached_future = hass.loop.create_future() + hass.data[DATA_MQTT_AVAILABLE] = state_reached_future else: state_reached_future = hass.data[DATA_MQTT_AVAILABLE] if state_reached_future.done(): diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 8344fb033b7..6800c403ee8 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -34,9 +34,9 @@ UNIT_OF_LOAD: Final[str] = "load" class NextcloudSensorEntityDescription(SensorEntityDescription): """Describes Nextcloud sensor entity.""" - value_fn: Callable[ - [str | int | float], str | int | float | datetime - ] = lambda value: value + value_fn: Callable[[str | int | float], str | int | float | datetime] = ( + lambda value: value + ) SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ diff --git a/homeassistant/components/onvif/base.py b/homeassistant/components/onvif/base.py index 8771ae7a701..5f8a7d978d1 100644 --- a/homeassistant/components/onvif/base.py +++ b/homeassistant/components/onvif/base.py @@ -32,8 +32,7 @@ class ONVIFBaseEntity(Entity): See: https://github.com/home-assistant/core/issues/35883 """ return ( - self.device.info.mac - or self.device.info.serial_number # type:ignore[return-value] + self.device.info.mac or self.device.info.serial_number # type:ignore[return-value] ) @property diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py index fcb83884694..7a9e50d7130 100644 --- a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py @@ -245,12 +245,13 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): MODE_CHANGE_STATE, OverkizCommandParam.AUTO, ).lower() # Overkiz can return states that have uppercase characters which are not accepted back as commands - if hvac_mode.replace( - " ", "" - ) in [ # Overkiz can return states like 'auto cooling' or 'autoHeating' that are not valid commands and need to be converted to 'auto' - OverkizCommandParam.AUTOCOOLING, - OverkizCommandParam.AUTOHEATING, - ]: + if ( + hvac_mode.replace(" ", "") + in [ # Overkiz can return states like 'auto cooling' or 'autoHeating' that are not valid commands and need to be converted to 'auto' + OverkizCommandParam.AUTOCOOLING, + OverkizCommandParam.AUTOHEATING, + ] + ): hvac_mode = OverkizCommandParam.AUTO swing_mode = self._control_backfill( diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index b332d057ba9..d15ed1163b7 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -83,13 +83,17 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda hass, service_info: bluetooth.async_get_learned_advertising_interval( - hass, service_info.address - ) - or bluetooth.async_get_fallback_availability_interval( - hass, service_info.address - ) - or bluetooth.FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + value_fn=( + lambda hass, service_info: ( + bluetooth.async_get_learned_advertising_interval( + hass, service_info.address + ) + or bluetooth.async_get_fallback_availability_interval( + hass, service_info.address + ) + or bluetooth.FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ) + ), suggested_display_precision=1, ), ) diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index aa036f33999..aedf917dd22 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -101,9 +101,8 @@ def _validate_table_schema_has_correct_collation( collate = ( dialect_kwargs.get("mysql_collate") - or dialect_kwargs.get( - "mariadb_collate" - ) # pylint: disable-next=protected-access + or dialect_kwargs.get("mariadb_collate") + # pylint: disable-next=protected-access or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] ) if collate and collate != "utf8mb4_unicode_ci": diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 06c8cf68903..b864e104ae6 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -176,13 +176,17 @@ class NativeLargeBinary(LargeBinary): # For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32 # for sqlite and postgresql we use a bigint UINT_32_TYPE = BigInteger().with_variant( - mysql.INTEGER(unsigned=True), "mysql", "mariadb" # type: ignore[no-untyped-call] + mysql.INTEGER(unsigned=True), # type: ignore[no-untyped-call] + "mysql", + "mariadb", ) JSON_VARIANT_CAST = Text().with_variant( - postgresql.JSON(none_as_null=True), "postgresql" # type: ignore[no-untyped-call] + postgresql.JSON(none_as_null=True), # type: ignore[no-untyped-call] + "postgresql", ) JSONB_VARIANT_CAST = Text().with_variant( - postgresql.JSONB(none_as_null=True), "postgresql" # type: ignore[no-untyped-call] + postgresql.JSONB(none_as_null=True), # type: ignore[no-untyped-call] + "postgresql", ) DATETIME_TYPE = ( DateTime(timezone=True) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index bf76c7264d5..fda8716df27 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -244,7 +244,8 @@ class Filters: ), # Needs https://github.com/bdraco/home-assistant/commit/bba91945006a46f3a01870008eb048e4f9cbb1ef self._generate_filter_for_columns( - (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder # type: ignore[arg-type] + (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), # type: ignore[arg-type] + _encoder, ).self_group(), ) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 8808ed2fd2b..427e3acab2d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -532,7 +532,9 @@ def _update_states_table_with_foreign_key_options( states_key_constraints = Base.metadata.tables[TABLE_STATES].foreign_key_constraints old_states_table = Table( # noqa: F841 - TABLE_STATES, MetaData(), *(alter["old_fk"] for alter in alters) # type: ignore[arg-type] + TABLE_STATES, + MetaData(), + *(alter["old_fk"] for alter in alters), # type: ignore[arg-type] ) for alter in alters: diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 8425f29fbe8..46761beae00 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -89,9 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): await host.renew() - async def async_check_firmware_update() -> str | Literal[ - False - ] | NewSoftwareVersion: + async def async_check_firmware_update() -> ( + str | Literal[False] | NewSoftwareVersion + ): """Check for firmware updates.""" if not host.api.supported(None, "update"): return False diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 179dd04cfaa..54a60d34229 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -566,10 +566,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) list_of_ports = {} for port in ports: - list_of_ports[ - port.device - ] = f"{port}, s/n: {port.serial_number or 'n/a'}" + ( - f" - {port.manufacturer}" if port.manufacturer else "" + list_of_ports[port.device] = ( + f"{port}, s/n: {port.serial_number or 'n/a'}" + + (f" - {port.manufacturer}" if port.manufacturer else "") ) list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 49caafcc774..27059bba180 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -280,9 +280,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def _async_fallback_poll(self) -> None: """Retrieve latest state by polling.""" - await self.hass.data[DATA_SONOS].favorites[ - self.speaker.household_id - ].async_poll() + await ( + self.hass.data[DATA_SONOS].favorites[self.speaker.household_id].async_poll() + ) await self.hass.async_add_executor_job(self._update) def _update(self) -> None: diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index a334171abb8..a3441eb76da 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -78,7 +78,9 @@ class RecorderOutput(StreamOutput): def write_segment(segment: Segment) -> None: """Write a segment to output.""" + # fmt: off nonlocal output, output_v, output_a, last_stream_id, running_duration, last_sequence + # fmt: on # Because the stream_worker is in a different thread from the record service, # the lookback segments may still have some overlap with the recorder segments if segment.sequence <= last_sequence: diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index b76699631cb..a2f08202319 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -153,7 +153,9 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C if not self.available: return None try: - return await self._api.surveillance_station.get_camera_image(self.entity_description.key, self.snapshot_quality) # type: ignore[no-any-return] + return await self._api.surveillance_station.get_camera_image( # type: ignore[no-any-return] + self.entity_description.key, self.snapshot_quality + ) except ( SynologyDSMAPIErrorException, SynologyDSMRequestException, diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 4e9149ebd07..0a00d1e79b4 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -57,7 +57,8 @@ from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_con from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( - set().union(Forecast.__annotations__.keys()) + set() + .union(Forecast.__annotations__.keys()) # Manually add the forecast resulting attributes that only exists # as native_* in the Forecast definition .union(("apparent_temperature", "wind_gust_speed", "dew_point")) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index b2b1cb31624..c23c1d5924e 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -119,9 +119,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle Memo Text service call.""" memo_text = call.data[CONF_MEMO_TEXT] memo_text.hass = hass - await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].get_module( - call.data[CONF_ADDRESS] - ).set_memo_text(memo_text.async_render()) + await ( + hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"] + .get_module(call.data[CONF_ADDRESS]) + .set_memo_text(memo_text.async_render()) + ) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index f3612c2d011..4277460c3ea 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -48,12 +48,12 @@ class VeSyncSensorEntityDescription( ): """Describe VeSync sensor entity.""" - exists_fn: Callable[ - [VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool - ] = lambda _: True - update_fn: Callable[ - [VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None - ] = lambda _: None + exists_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool] = ( + lambda _: True + ) + update_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None] = ( + lambda _: None + ) def update_energy(device): diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 1bda3b1595d..8d9cb444fc9 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -28,9 +28,9 @@ NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] class VodafoneStationBaseEntityDescription: """Vodafone Station entity base description.""" - value: Callable[ - [Any, Any], Any - ] = lambda coordinator, key: coordinator.data.sensors[key] + value: Callable[[Any, Any], Any] = ( + lambda coordinator, key: coordinator.data.sensors[key] + ) is_suitable: Callable[[dict], bool] = lambda val: True diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 14e1211639e..120f2d9559b 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -111,11 +111,13 @@ class HassVoipDatagramProtocol(VoipDatagramProtocol): valid_protocol_factory=lambda call_info, rtcp_state: make_protocol( hass, devices, call_info, rtcp_state ), - invalid_protocol_factory=lambda call_info, rtcp_state: PreRecordMessageProtocol( - hass, - "not_configured.pcm", - opus_payload_type=call_info.opus_payload_type, - rtcp_state=rtcp_state, + invalid_protocol_factory=( + lambda call_info, rtcp_state: PreRecordMessageProtocol( + hass, + "not_configured.pcm", + opus_payload_type=call_info.opus_payload_type, + rtcp_state=rtcp_state, + ) ), ) self.hass = hass diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 94153a47fdc..b64f5aba6b7 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -95,9 +95,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.upnp_description = discovery_info.ssdp_location # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment - self.host = urlparse( - discovery_info.ssdp_location - ).hostname # type: ignore[assignment] + self.host = urlparse(discovery_info.ssdp_location).hostname # type: ignore[assignment] await self.async_set_unique_id(self.serial_number) self._abort_if_unique_id_configured( diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index ef5cdd1b1d2..acd6780d39f 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -276,9 +276,7 @@ async def async_setup_entry( if state_key == "0": continue - notification_description: NotificationZWaveJSEntityDescription | None = ( - None - ) + notification_description: NotificationZWaveJSEntityDescription | None = None for description in NOTIFICATION_SENSOR_MAPPINGS: if ( diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 37cfdc68569..cf743a3e85a 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -344,7 +344,8 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): is not None and (extra_data := await self.async_get_last_extra_data()) and ( - latest_version_firmware := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + latest_version_firmware + := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 648e0e5bd09..1de7a6c6a43 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -251,7 +251,9 @@ def async_track_state_change( return async_track_state_change_event(hass, entity_ids, state_change_listener) return hass.bus.async_listen( - EVENT_STATE_CHANGED, state_change_dispatcher, event_filter=state_change_filter # type: ignore[arg-type] + EVENT_STATE_CHANGED, + state_change_dispatcher, # type: ignore[arg-type] + event_filter=state_change_filter, # type: ignore[arg-type] ) @@ -761,7 +763,8 @@ class _TrackStateChangeFiltered: @callback def _setup_all_listener(self) -> None: self._listeners[_ALL_LISTENER] = self.hass.bus.async_listen( - EVENT_STATE_CHANGED, self._action # type: ignore[arg-type] + EVENT_STATE_CHANGED, + self._action, # type: ignore[arg-type] ) @@ -1335,7 +1338,8 @@ def async_track_same_state( if entity_ids == MATCH_ALL: async_remove_state_for_cancel = hass.bus.async_listen( - EVENT_STATE_CHANGED, state_for_cancel_listener # type: ignore[arg-type] + EVENT_STATE_CHANGED, + state_for_cancel_listener, # type: ignore[arg-type] ) else: async_remove_state_for_cancel = async_track_state_change_event( diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 4dd71a584ec..625bab8b218 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -190,7 +190,8 @@ class RestoreStateData: state, self.entities[state.entity_id].extra_restore_state_data, now ) for state in all_states - if state.entity_id in self.entities and + if state.entity_id in self.entities + and # Ignore all states that are entity registry placeholders not state.attributes.get(ATTR_RESTORED) ] diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index b74c22c9ead..606b90e6005 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -99,8 +99,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): # Pick a random microsecond in range 0.05..0.50 to stagger the refreshes # and avoid a thundering herd. self._microsecond = ( - randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) - / 10**6 + randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) / 10**6 ) self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ce868ab85f3..6fb538a5aef 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -403,9 +403,7 @@ async def async_get_zeroconf( hass: HomeAssistant, ) -> dict[str, list[dict[str, str | dict[str, str]]]]: """Return cached list of zeroconf types.""" - zeroconf: dict[ - str, list[dict[str, str | dict[str, str]]] - ] = ZEROCONF.copy() # type: ignore[assignment] + zeroconf: dict[str, list[dict[str, str | dict[str, str]]]] = ZEROCONF.copy() # type: ignore[assignment] integrations = await async_get_custom_components(hass) for integration in integrations.values(): @@ -1013,9 +1011,7 @@ def _load_file( Async friendly. """ with suppress(KeyError): - return hass.data[DATA_COMPONENTS][ # type: ignore[no-any-return] - comp_or_platform - ] + return hass.data[DATA_COMPONENTS][comp_or_platform] # type: ignore[no-any-return] cache = hass.data[DATA_COMPONENTS] diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 7f81c281340..ac18d43727c 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -57,7 +57,8 @@ def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObject def load_json( - filename: str | PathLike, default: JsonValueType = _SENTINEL # type: ignore[assignment] + filename: str | PathLike, + default: JsonValueType = _SENTINEL, # type: ignore[assignment] ) -> JsonValueType: """Load JSON data from a file. @@ -79,7 +80,8 @@ def load_json( def load_json_array( - filename: str | PathLike, default: JsonArrayType = _SENTINEL # type: ignore[assignment] + filename: str | PathLike, + default: JsonArrayType = _SENTINEL, # type: ignore[assignment] ) -> JsonArrayType: """Load JSON data from a file and return as list. @@ -98,7 +100,8 @@ def load_json_array( def load_json_object( - filename: str | PathLike, default: JsonObjectType = _SENTINEL # type: ignore[assignment] + filename: str | PathLike, + default: JsonObjectType = _SENTINEL, # type: ignore[assignment] ) -> JsonObjectType: """Load JSON data from a file and return as dict. diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 44fcaa07067..b2ef7330660 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -129,6 +129,7 @@ def vincenty( uSq = cosSqAlpha * (AXIS_A**2 - AXIS_B**2) / (AXIS_B**2) A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq))) B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq))) + # fmt: off deltaSigma = ( B * sinSigma @@ -141,11 +142,12 @@ def vincenty( - B / 6 * cos2SigmaM - * (-3 + 4 * sinSigma**2) - * (-3 + 4 * cos2SigmaM**2) + * (-3 + 4 * sinSigma ** 2) + * (-3 + 4 * cos2SigmaM ** 2) ) ) ) + # fmt: on s = AXIS_B * A * (sigma - deltaSigma) s /= 1000 # Conversion of meters to kilometers diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index e1cfc81019c..fbffae448b2 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -340,7 +340,12 @@ def _handle_mapping_tag( raise yaml.MarkedYAMLError( context=f'invalid key: "{key}"', context_mark=yaml.Mark( - fname, 0, line, -1, None, None # type: ignore[arg-type] + fname, + 0, + line, + -1, + None, + None, # type: ignore[arg-type] ), ) from exc diff --git a/pyproject.toml b/pyproject.toml index 13bcc9987ff..7b822bd7a7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,9 +79,6 @@ include-package-data = true [tool.setuptools.packages.find] include = ["homeassistant*"] -[tool.black] -extend-exclude = "/generated/" - [tool.pylint.MAIN] py-version = "3.11" ignore = [ diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 03c46de6b37..c797db4b7a3 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.11.0 codespell==2.2.2 -ruff==0.1.1 +ruff==0.1.6 yamllint==1.32.0 diff --git a/script/check_format b/script/check_format index bed35ec63e4..09dbb0abe86 100755 --- a/script/check_format +++ b/script/check_format @@ -1,10 +1,10 @@ #!/bin/sh -# Format code with black. +# Format code with ruff-format. cd "$(dirname "$0")/.." -black \ +ruff \ + format \ --check \ - --fast \ --quiet \ homeassistant tests script *.py diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 404d257c414..f62d6e936a7 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -192,6 +192,7 @@ IGNORE_PRE_COMMIT_HOOK_ID = ( "no-commit-to-branch", "prettier", "python-typing-update", + "ruff-format", # it's just ruff ) PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$") @@ -394,7 +395,8 @@ def requirements_test_all_output(reqs: dict[str, list[str]]) -> str: for requirement, modules in reqs.items() if any( # Always install requirements that are not part of integrations - not mdl.startswith("homeassistant.components.") or + not mdl.startswith("homeassistant.components.") + or # Install tests for integrations that have tests has_tests(mdl) for mdl in modules diff --git a/script/hassfest/serializer.py b/script/hassfest/serializer.py index 499ee9d51d9..b56306a8d7e 100644 --- a/script/hassfest/serializer.py +++ b/script/hassfest/serializer.py @@ -2,11 +2,10 @@ from __future__ import annotations from collections.abc import Collection, Iterable, Mapping +import shutil +import subprocess from typing import Any -import black -from black.mode import Mode - DEFAULT_GENERATOR = "script.hassfest" @@ -72,7 +71,14 @@ To update, run python3 -m {generator} {content} """ - return black.format_str(content.strip(), mode=Mode()) + ruff = shutil.which("ruff") + if not ruff: + raise RuntimeError("ruff not found") + return subprocess.check_output( + [ruff, "format", "-"], + input=content.strip(), + encoding="utf-8", + ) def format_python_namespace( diff --git a/tests/common.py b/tests/common.py index 30ea779295c..a4979c85853 100644 --- a/tests/common.py +++ b/tests/common.py @@ -267,7 +267,7 @@ async def async_test_home_assistant(event_loop, load_registries=True): "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump", return_value=None, ), patch( - "homeassistant.helpers.restore_state.start.async_at_start" + "homeassistant.helpers.restore_state.start.async_at_start", ): await asyncio.gather( ar.async_load(hass), diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py index 4376db23366..9ebe13c83e6 100644 --- a/tests/components/airvisual_pro/conftest.py +++ b/tests/components/airvisual_pro/conftest.py @@ -78,9 +78,7 @@ async def setup_airvisual_pro_fixture(hass, config, pro): "homeassistant.components.airvisual_pro.config_flow.NodeSamba", return_value=pro ), patch( "homeassistant.components.airvisual_pro.NodeSamba", return_value=pro - ), patch( - "homeassistant.components.airvisual.PLATFORMS", [] - ): + ), patch("homeassistant.components.airvisual.PLATFORMS", []): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 4e51880c754..d22738a7e6b 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -180,9 +180,11 @@ async def test_send_base_with_supervisor( "homeassistant.components.hassio.is_hassio", side_effect=Mock(return_value=True), ), patch( - "uuid.UUID.hex", new_callable=PropertyMock + "uuid.UUID.hex", + new_callable=PropertyMock, ) as hex, patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + "homeassistant.components.analytics.analytics.HA_VERSION", + MOCK_VERSION, ): hex.return_value = MOCK_UUID await analytics.load() @@ -289,7 +291,8 @@ async def test_send_usage_with_supervisor( "homeassistant.components.hassio.is_hassio", side_effect=Mock(return_value=True), ), patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + "homeassistant.components.analytics.analytics.HA_VERSION", + MOCK_VERSION, ): await analytics.send_analytics() assert ( @@ -492,7 +495,8 @@ async def test_send_statistics_with_supervisor( "homeassistant.components.hassio.is_hassio", side_effect=Mock(return_value=True), ), patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + "homeassistant.components.analytics.analytics.HA_VERSION", + MOCK_VERSION, ): await analytics.send_analytics() assert "'addon_count': 1" in caplog.text diff --git a/tests/components/anova/__init__.py b/tests/components/anova/__init__.py index 5bcb84cb974..aa58ee5bbb5 100644 --- a/tests/components/anova/__init__.py +++ b/tests/components/anova/__init__.py @@ -51,7 +51,7 @@ async def async_init_integration( ) as update_patch, patch( "homeassistant.components.anova.AnovaApi.authenticate" ), patch( - "homeassistant.components.anova.AnovaApi.get_devices" + "homeassistant.components.anova.AnovaApi.get_devices", ) as device_patch: update_patch.return_value = ONLINE_UPDATE device_patch.return_value = [ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 9d3b9889cd3..e23f86e545b 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -92,7 +92,8 @@ async def test_load_backups(hass: HomeAssistant) -> None: "date": TEST_BACKUP.date, }, ), patch( - "pathlib.Path.stat", return_value=MagicMock(st_size=TEST_BACKUP.size) + "pathlib.Path.stat", + return_value=MagicMock(st_size=TEST_BACKUP.size), ): await manager.load_backups() backups = await manager.get_backups() diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index ab04499c827..ada38451754 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -120,7 +120,8 @@ async def test_form_2fa_connect_error(hass: HomeAssistant) -> None: "homeassistant.components.blink.config_flow.Blink.setup_urls", side_effect=BlinkSetupError, ), patch( - "homeassistant.components.blink.async_setup_entry", return_value=True + "homeassistant.components.blink.async_setup_entry", + return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], {"pin": "1234"} @@ -161,7 +162,8 @@ async def test_form_2fa_invalid_key(hass: HomeAssistant) -> None: "homeassistant.components.blink.config_flow.Blink.setup_urls", return_value=True, ), patch( - "homeassistant.components.blink.async_setup_entry", return_value=True + "homeassistant.components.blink.async_setup_entry", + return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], {"pin": "1234"} @@ -200,7 +202,8 @@ async def test_form_2fa_unknown_error(hass: HomeAssistant) -> None: "homeassistant.components.blink.config_flow.Blink.setup_urls", side_effect=KeyError, ), patch( - "homeassistant.components.blink.async_setup_entry", return_value=True + "homeassistant.components.blink.async_setup_entry", + return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], {"pin": "1234"} diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 59c5cc822df..5f166a3fca2 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -47,12 +47,14 @@ def mock_operating_system_90(): def macos_adapter(): """Fixture that mocks the macos adapter.""" with patch("bleak.get_platform_scanner_backend_type"), patch( - "homeassistant.components.bluetooth.platform.system", return_value="Darwin" + "homeassistant.components.bluetooth.platform.system", + return_value="Darwin", ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Darwin", ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Darwin" + "bluetooth_adapters.systems.platform.system", + return_value="Darwin", ): yield @@ -71,14 +73,16 @@ def windows_adapter(): def no_adapter_fixture(): """Fixture that mocks no adapters on Linux.""" with patch( - "homeassistant.components.bluetooth.platform.system", return_value="Linux" + "homeassistant.components.bluetooth.platform.system", + return_value="Linux", ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Linux", ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Linux" + "bluetooth_adapters.systems.platform.system", + return_value="Linux", ), patch( - "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" + "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", {}, @@ -90,14 +94,16 @@ def no_adapter_fixture(): def one_adapter_fixture(): """Fixture that mocks one adapter on Linux.""" with patch( - "homeassistant.components.bluetooth.platform.system", return_value="Linux" + "homeassistant.components.bluetooth.platform.system", + return_value="Linux", ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Linux", ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Linux" + "bluetooth_adapters.systems.platform.system", + return_value="Linux", ), patch( - "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" + "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", { @@ -124,9 +130,7 @@ def two_adapters_fixture(): ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Linux", - ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Linux" - ), patch( + ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", @@ -166,9 +170,7 @@ def one_adapter_old_bluez(): ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Linux", - ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Linux" - ), patch( + ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 6fbcb928b5a..ff1f986583e 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -67,13 +67,9 @@ async def setup_bond_entity( enabled=patch_token ), patch_bond_version(enabled=patch_version), patch_bond_device_ids( enabled=patch_device_ids - ), patch_setup_entry( - "cover", enabled=patch_platforms - ), patch_setup_entry( + ), patch_setup_entry("cover", enabled=patch_platforms), patch_setup_entry( "fan", enabled=patch_platforms - ), patch_setup_entry( - "light", enabled=patch_platforms - ), patch_setup_entry( + ), patch_setup_entry("light", enabled=patch_platforms), patch_setup_entry( "switch", enabled=patch_platforms ): return await hass.config_entries.async_setup(config_entry.entry_id) @@ -102,15 +98,11 @@ async def setup_platform( "homeassistant.components.bond.PLATFORMS", [platform] ), patch_bond_version(return_value=bond_version), patch_bond_bridge( return_value=bridge - ), patch_bond_token( - return_value=token - ), patch_bond_device_ids( + ), patch_bond_token(return_value=token), patch_bond_device_ids( return_value=[bond_device_id] ), patch_start_bpup(), patch_bond_device( return_value=discovered_device - ), patch_bond_device_properties( - return_value=props - ), patch_bond_device_state( + ), patch_bond_device_properties(return_value=props), patch_bond_device_state( return_value=state ): assert await async_setup_component(hass, BOND_DOMAIN, {}) diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 92c11028173..6b462a02c26 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -184,9 +184,7 @@ async def test_old_identifiers_are_removed( "name": "test1", "type": DeviceType.GENERIC_DEVICE, } - ), patch_bond_device_properties( - return_value={} - ), patch_bond_device_state( + ), patch_bond_device_properties(return_value={}), patch_bond_device_state( return_value={} ): assert await hass.config_entries.async_setup(config_entry.entry_id) is True @@ -228,9 +226,7 @@ async def test_smart_by_bond_device_suggested_area( "type": DeviceType.GENERIC_DEVICE, "location": "Den", } - ), patch_bond_device_properties( - return_value={} - ), patch_bond_device_state( + ), patch_bond_device_properties(return_value={}), patch_bond_device_state( return_value={} ): assert await hass.config_entries.async_setup(config_entry.entry_id) is True @@ -275,9 +271,7 @@ async def test_bridge_device_suggested_area( "type": DeviceType.GENERIC_DEVICE, "location": "Bathroom", } - ), patch_bond_device_properties( - return_value={} - ), patch_bond_device_state( + ), patch_bond_device_properties(return_value={}), patch_bond_device_state( return_value={} ): assert await hass.config_entries.async_setup(config_entry.entry_id) is True diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 2d688489d39..9b5c2d56d4c 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -19,7 +19,7 @@ async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: ) as mock_setup, patch( "pychromecast.discovery.discover_chromecasts", return_value=(True, None) ), patch( - "pychromecast.discovery.stop_discovery" + "pychromecast.discovery.stop_discovery", ): result = await hass.config_entries.flow.async_init( cast.DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index f2d59f46114..dd15eca05cd 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -24,7 +24,7 @@ async def test_user(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.comelit.async_setup_entry" ) as mock_setup_entry, patch( - "requests.get" + "requests.get", ) as mock_request_get: mock_request_get.return_value.status_code = 200 @@ -70,7 +70,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> ), patch( "aiocomelit.api.ComeliteSerialBridgeApi.logout", ), patch( - "homeassistant.components.comelit.async_setup_entry" + "homeassistant.components.comelit.async_setup_entry", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -135,9 +135,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> "aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect ), patch( "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), patch( - "homeassistant.components.comelit.async_setup_entry" - ): + ), patch("homeassistant.components.comelit.async_setup_entry"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index ad4c7e90851..1a099c05b16 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -23,7 +23,9 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture async def setup_automation( - hass, automation_config, stub_blueprint_populate # noqa: F811 + hass, + automation_config, + stub_blueprint_populate, # noqa: F811 ): """Set up automation integration.""" assert await async_setup_component( diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index 93a6305655b..a0fb908d920 100644 --- a/tests/components/denonavr/test_config_flow.py +++ b/tests/components/denonavr/test_config_flow.py @@ -65,7 +65,8 @@ def denonavr_connect_fixture(): "homeassistant.components.denonavr.receiver.DenonAVR.receiver_type", TEST_RECEIVER_TYPE, ), patch( - "homeassistant.components.denonavr.async_setup_entry", return_value=True + "homeassistant.components.denonavr.async_setup_entry", + return_value=True, ): yield diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 47933c30537..5013568ad39 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -151,8 +151,11 @@ async def _async_get_handle_dhcp_packet(hass, integration_matchers): with patch( "homeassistant.components.dhcp._verify_l2socket_setup", ), patch( - "scapy.arch.common.compile_filter" - ), patch("scapy.sendrecv.AsyncSniffer", _mock_sniffer): + "scapy.arch.common.compile_filter", + ), patch( + "scapy.sendrecv.AsyncSniffer", + _mock_sniffer, + ): await dhcp_watcher.async_start() return async_handle_dhcp_packet diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 7d79a10e912..a0f34e3cd21 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -198,9 +198,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_t return_value=MOCK_ECOBEE_CONF, ), patch( "homeassistant.components.ecobee.config_flow.Ecobee" - ) as mock_ecobee, patch.object( - flow, "async_step_user" - ) as mock_async_step_user: + ) as mock_ecobee, patch.object(flow, "async_step_user") as mock_async_step_user: mock_ecobee = mock_ecobee.return_value mock_ecobee.refresh_tokens.return_value = False diff --git a/tests/components/electrasmart/test_config_flow.py b/tests/components/electrasmart/test_config_flow.py index f53bea3e96c..929259a0ccf 100644 --- a/tests/components/electrasmart/test_config_flow.py +++ b/tests/components/electrasmart/test_config_flow.py @@ -55,7 +55,8 @@ async def test_one_time_password(hass: HomeAssistant): "electrasmart.api.ElectraAPI.validate_one_time_password", return_value=mock_otp_response, ), patch( - "electrasmart.api.ElectraAPI.fetch_devices", return_value=[] + "electrasmart.api.ElectraAPI.fetch_devices", + return_value=[], ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index 216fc019778..5e33a8aa4c3 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -229,9 +229,7 @@ async def test_form_user_with_insecure_elk_times_out(hass: HomeAssistant) -> Non 0, ), patch( "homeassistant.components.elkm1.config_flow.LOGIN_TIMEOUT", 0 - ), _patch_discovery(), _patch_elk( - elk=mocked_elk - ): + ), _patch_discovery(), _patch_elk(elk=mocked_elk): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 41cbb239129..c1fb03545cb 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -89,7 +89,8 @@ async def setup_enphase_envoy_fixture(hass, config, mock_envoy): "homeassistant.components.enphase_envoy.Envoy", return_value=mock_envoy, ), patch( - "homeassistant.components.enphase_envoy.PLATFORMS", [] + "homeassistant.components.enphase_envoy.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py index d44036c680c..8d6af04c174 100644 --- a/tests/components/epson/test_media_player.py +++ b/tests/components/epson/test_media_player.py @@ -38,7 +38,7 @@ async def test_set_unique_id( ), patch( "homeassistant.components.epson.Projector.get_serial_number", return_value="123" ), patch( - "homeassistant.components.epson.Projector.get_property" + "homeassistant.components.epson.Projector.get_property", ): freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index d7b04f8448c..9ab00421cbc 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -100,7 +100,8 @@ async def test_update_entity( ) as mock_compile, patch( "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True ) as mock_upload, pytest.raises( - HomeAssistantError, match="compiling" + HomeAssistantError, + match="compiling", ): await hass.services.async_call( "update", @@ -120,7 +121,8 @@ async def test_update_entity( ) as mock_compile, patch( "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=False ) as mock_upload, pytest.raises( - HomeAssistantError, match="OTA" + HomeAssistantError, + match="OTA", ): await hass.services.async_call( "update", diff --git a/tests/components/evil_genius_labs/conftest.py b/tests/components/evil_genius_labs/conftest.py index 66dd8979d67..a4f10fe97c4 100644 --- a/tests/components/evil_genius_labs/conftest.py +++ b/tests/components/evil_genius_labs/conftest.py @@ -51,7 +51,8 @@ async def setup_evil_genius_labs( "pyevilgenius.EvilGeniusDevice.get_product", return_value=product_fixture, ), patch( - "homeassistant.components.evil_genius_labs.PLATFORMS", platforms + "homeassistant.components.evil_genius_labs.PLATFORMS", + platforms, ): assert await async_setup_component(hass, "evil_genius_labs", {}) await hass.async_block_till_done() diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index bb34af7c400..ded7cda0dea 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -48,9 +48,9 @@ async def test_user(hass: HomeAssistant, fc_class_mock, mock_get_source_ip) -> N ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( - "requests.get" + "requests.get", ) as mock_request_get, patch( - "requests.post" + "requests.post", ) as mock_request_post, patch( "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], @@ -98,9 +98,9 @@ async def test_user_already_configured( "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch( - "requests.get" + "requests.get", ) as mock_request_get, patch( - "requests.post" + "requests.post", ) as mock_request_post, patch( "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], @@ -211,11 +211,11 @@ async def test_reauth_successful( "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch( - "homeassistant.components.fritz.async_setup_entry" + "homeassistant.components.fritz.async_setup_entry", ) as mock_setup_entry, patch( - "requests.get" + "requests.get", ) as mock_request_get, patch( - "requests.post" + "requests.post", ) as mock_request_post: mock_request_get.return_value.status_code = 200 mock_request_get.return_value.content = MOCK_REQUEST @@ -399,9 +399,7 @@ async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip) -> N return_value=MOCK_FIRMWARE_INFO, ), patch( "homeassistant.components.fritz.async_setup_entry" - ) as mock_setup_entry, patch( - "requests.get" - ) as mock_request_get, patch( + ) as mock_setup_entry, patch("requests.get") as mock_request_get, patch( "requests.post" ) as mock_request_post: mock_request_get.return_value.status_code = 200 diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 946cceac786..4e69420f66e 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -43,7 +43,8 @@ async def init_integration( "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors, ), patch( - "homeassistant.components.gios.Gios._get_indexes", return_value=indexes + "homeassistant.components.gios.Gios._get_indexes", + return_value=indexes, ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index 3d52c122791..efe46be9b8d 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -55,7 +55,8 @@ async def test_invalid_sensor_data(hass: HomeAssistant) -> None: "homeassistant.components.gios.Gios._get_station", return_value=json.loads(load_fixture("gios/station.json")), ), patch( - "homeassistant.components.gios.Gios._get_sensor", return_value={} + "homeassistant.components.gios.Gios._get_sensor", + return_value={}, ): flow = config_flow.GiosFlowHandler() flow.hass = hass @@ -83,7 +84,8 @@ async def test_cannot_connect(hass: HomeAssistant) -> None: async def test_create_entry(hass: HomeAssistant) -> None: """Test that the user step works.""" with patch( - "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS + "homeassistant.components.gios.Gios._get_stations", + return_value=STATIONS, ), patch( "homeassistant.components.gios.Gios._get_station", return_value=json.loads(load_fixture("gios/station.json")), diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py index 0d4484c6d0d..d20aecad3df 100644 --- a/tests/components/gios/test_init.py +++ b/tests/components/gios/test_init.py @@ -82,9 +82,7 @@ async def test_migrate_device_and_config_entry( ), patch( "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors, - ), patch( - "homeassistant.components.gios.Gios._get_indexes", return_value=indexes - ): + ), patch("homeassistant.components.gios.Gios._get_indexes", return_value=indexes): config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 62d2722c445..aa7f8472cab 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -92,7 +92,7 @@ async def test_update_access_token(hass: HomeAssistant) -> None: ) as mock_get_token, patch( "homeassistant.components.google_assistant.http._get_homegraph_jwt" ) as mock_get_jwt, patch( - "homeassistant.core.dt_util.utcnow" + "homeassistant.core.dt_util.utcnow", ) as mock_utcnow: mock_utcnow.return_value = base_time mock_get_jwt.return_value = jwt diff --git a/tests/components/google_assistant_sdk/test_notify.py b/tests/components/google_assistant_sdk/test_notify.py index f35d19e3805..cf3f90097ce 100644 --- a/tests/components/google_assistant_sdk/test_notify.py +++ b/tests/components/google_assistant_sdk/test_notify.py @@ -66,7 +66,12 @@ async def test_broadcast_no_targets( "Anuncia en el salón Es hora de hacer los deberes", ), ("ko-KR", "숙제할 시간이야", "거실", "숙제할 시간이야 라고 거실에 방송해 줘"), - ("ja-JP", "宿題の時間だよ", "リビング", "宿題の時間だよとリビングにブロードキャストして"), + ( + "ja-JP", + "宿題の時間だよ", + "リビング", + "宿題の時間だよとリビングにブロードキャストして", + ), ], ids=["english", "spanish", "korean", "japanese"], ) diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index acf59aeea86..f2cde0a553d 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -131,9 +131,10 @@ async def setup_guardian_fixture( "aioguardian.commands.wifi.WiFiCommands.status", return_value=data_wifi_status, ), patch( - "aioguardian.client.Client.disconnect" + "aioguardian.client.Client.disconnect", ), patch( - "homeassistant.components.guardian.PLATFORMS", [] + "homeassistant.components.guardian.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 22051808ccc..0cce33f6dfd 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -54,9 +54,9 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": []}, ), patch( - "homeassistant.components.hassio.issues.SupervisorIssues.setup" + "homeassistant.components.hassio.issues.SupervisorIssues.setup", ), patch( - "homeassistant.components.hassio.HassIO.refresh_updates" + "homeassistant.components.hassio.HassIO.refresh_updates", ): hass.state = CoreState.starting hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index fe151c902cb..8c6d4328065 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -31,7 +31,7 @@ def run_driver(hass, event_loop, iid_storage): ), patch("pyhap.accessory_driver.HAPServer"), patch( "pyhap.accessory_driver.AccessoryDriver.publish" ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + "pyhap.accessory_driver.AccessoryDriver.persist", ): yield HomeDriver( hass, @@ -53,9 +53,9 @@ def hk_driver(hass, event_loop, iid_storage): ), patch("pyhap.accessory_driver.HAPServer.async_stop"), patch( "pyhap.accessory_driver.HAPServer.async_start" ), patch( - "pyhap.accessory_driver.AccessoryDriver.publish" + "pyhap.accessory_driver.AccessoryDriver.publish", ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + "pyhap.accessory_driver.AccessoryDriver.persist", ): yield HomeDriver( hass, @@ -77,13 +77,13 @@ def mock_hap(hass, event_loop, iid_storage, mock_zeroconf): ), patch("pyhap.accessory_driver.HAPServer.async_stop"), patch( "pyhap.accessory_driver.HAPServer.async_start" ), patch( - "pyhap.accessory_driver.AccessoryDriver.publish" + "pyhap.accessory_driver.AccessoryDriver.publish", ), patch( - "pyhap.accessory_driver.AccessoryDriver.async_start" + "pyhap.accessory_driver.AccessoryDriver.async_start", ), patch( - "pyhap.accessory_driver.AccessoryDriver.async_stop" + "pyhap.accessory_driver.AccessoryDriver.async_stop", ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + "pyhap.accessory_driver.AccessoryDriver.persist", ): yield HomeDriver( hass, diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 158efa477d4..1d42325d54c 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1202,9 +1202,7 @@ async def test_homekit_reset_accessories_not_supported( "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" - ), patch.object( - homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 - ): + ), patch.object(homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0): await async_init_entry(hass, entry) acc_mock = MagicMock() @@ -1247,9 +1245,7 @@ async def test_homekit_reset_accessories_state_missing( "pyhap.accessory_driver.AccessoryDriver.config_changed" ) as hk_driver_config_changed, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" - ), patch.object( - homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 - ): + ), patch.object(homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0): await async_init_entry(hass, entry) acc_mock = MagicMock() @@ -1291,9 +1287,7 @@ async def test_homekit_reset_accessories_not_bridged( "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" - ), patch.object( - homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 - ): + ), patch.object(homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0): await async_init_entry(hass, entry) assert hk_driver_async_update_advertisement.call_count == 0 @@ -1338,7 +1332,7 @@ async def test_homekit_reset_single_accessory( ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch( - f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" + f"{PATH_HOMEKIT}.accessories.HomeAccessory.run", ) as mock_run: await async_init_entry(hass, entry) homekit.status = STATUS_RUNNING @@ -2071,9 +2065,9 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: ) as mock_homekit2, patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.async_show_setup_message" ), patch( - f"{PATH_HOMEKIT}.get_accessory" + f"{PATH_HOMEKIT}.get_accessory", ), patch( - "pyhap.accessory_driver.AccessoryDriver.async_start" + "pyhap.accessory_driver.AccessoryDriver.async_start", ), patch( "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" ): diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 909e94a0d84..b1f063615f3 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -102,7 +102,7 @@ async def test_hmip_add_device( ), patch.object(reloaded_hap, "async_connect"), patch.object( reloaded_hap, "get_hap", return_value=mock_hap.home ), patch( - "homeassistant.components.homematicip_cloud.hap.asyncio.sleep" + "homeassistant.components.homematicip_cloud.hap.asyncio.sleep", ): mock_hap.home.fire_create_event(event_type=EventType.DEVICE_ADDED) await hass.async_block_till_done() diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 4569a6fff6b..0d950968191 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -53,7 +53,8 @@ async def test_auth_auth_check_and_register(hass: HomeAssistant) -> None: ), patch.object( hmip_auth.auth, "requestAuthToken", return_value="ABC" ), patch.object( - hmip_auth.auth, "confirmAuthToken" + hmip_auth.auth, + "confirmAuthToken", ): assert await hmip_auth.async_checkbutton() assert await hmip_auth.async_register() == "ABC" diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index 7b61b42c9d2..646e9e4da86 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -114,7 +114,8 @@ async def test_setup_devices_exception( "homeassistant.components.iaqualink.AqualinkClient.get_systems", return_value=systems, ), patch.object( - system, "get_devices" + system, + "get_devices", ) as mock_get_devices: mock_get_devices.side_effect = AqualinkServiceException await hass.config_entries.async_setup(config_entry.entry_id) @@ -142,7 +143,8 @@ async def test_setup_all_good_no_recognized_devices( "homeassistant.components.iaqualink.AqualinkClient.get_systems", return_value=systems, ), patch.object( - system, "get_devices" + system, + "get_devices", ) as mock_get_devices: mock_get_devices.return_value = devices await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/insteon/test_init.py b/tests/components/insteon/test_init.py index 15f529babd8..f772eed2d26 100644 --- a/tests/components/insteon/test_init.py +++ b/tests/components/insteon/test_init.py @@ -76,7 +76,8 @@ async def test_import_frontend_dev_url(hass: HomeAssistant) -> None: ), patch.object(insteon, "close_insteon_connection"), patch.object( insteon, "devices", new=MockDevices() ), patch( - PATCH_CONNECTION, new=mock_successful_connection + PATCH_CONNECTION, + new=mock_successful_connection, ): assert await async_setup_component( hass, diff --git a/tests/components/insteon/test_lock.py b/tests/components/insteon/test_lock.py index f96e33af1c8..c100acae3ce 100644 --- a/tests/components/insteon/test_lock.py +++ b/tests/components/insteon/test_lock.py @@ -47,7 +47,9 @@ def patch_setup_and_devices(): ), patch.object(insteon, "devices", devices), patch.object( insteon_utils, "devices", devices ), patch.object( - insteon_entity, "devices", devices + insteon_entity, + "devices", + devices, ): yield diff --git a/tests/components/iqvia/conftest.py b/tests/components/iqvia/conftest.py index 075d7249d36..b24d473c7df 100644 --- a/tests/components/iqvia/conftest.py +++ b/tests/components/iqvia/conftest.py @@ -94,13 +94,9 @@ async def setup_iqvia_fixture( "pyiqvia.allergens.Allergens.outlook", return_value=data_allergy_outlook ), patch( "pyiqvia.asthma.Asthma.extended", return_value=data_asthma_forecast - ), patch( - "pyiqvia.asthma.Asthma.current", return_value=data_asthma_index - ), patch( + ), patch("pyiqvia.asthma.Asthma.current", return_value=data_asthma_index), patch( "pyiqvia.disease.Disease.extended", return_value=data_disease_forecast - ), patch( - "pyiqvia.disease.Disease.current", return_value=data_disease_index - ), patch( + ), patch("pyiqvia.disease.Disease.current", return_value=data_disease_index), patch( "homeassistant.components.iqvia.PLATFORMS", [] ): assert await async_setup_component(hass, DOMAIN, config) diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 5d42ed79542..0f2d8e56050 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -77,9 +77,9 @@ def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None): return_value=return_value, side_effect=side_effect, ), patch( - "pathlib.Path.mkdir" + "pathlib.Path.mkdir", ) as mkdir_mock, patch( - "shutil.move" + "shutil.move", ) as shutil_move_mock: file_upload_mock.return_value.__enter__.return_value = Mock() yield return_value diff --git a/tests/components/linear_garage_door/test_config_flow.py b/tests/components/linear_garage_door/test_config_flow.py index 88cfca71f98..64664745c54 100644 --- a/tests/components/linear_garage_door/test_config_flow.py +++ b/tests/components/linear_garage_door/test_config_flow.py @@ -30,7 +30,8 @@ async def test_form(hass: HomeAssistant) -> None: "homeassistant.components.linear_garage_door.config_flow.Linear.close", return_value=None, ), patch( - "uuid.uuid4", return_value="test-uuid" + "uuid.uuid4", + return_value="test-uuid", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -89,7 +90,8 @@ async def test_reauth(hass: HomeAssistant) -> None: "homeassistant.components.linear_garage_door.config_flow.Linear.close", return_value=None, ), patch( - "uuid.uuid4", return_value="test-uuid" + "uuid.uuid4", + return_value="test-uuid", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index eaa2a1e4192..d95b409a67b 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -493,9 +493,13 @@ async def test_logbook_describe_event( hass, "fake_integration.logbook", Mock( - async_describe_events=lambda hass, async_describe_event: async_describe_event( - "test_domain", "some_event", _describe - ) + async_describe_events=( + lambda hass, async_describe_event: async_describe_event( + "test_domain", + "some_event", + _describe, + ) + ), ), ) diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index da26a55a4ef..631cb0ff1e7 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -60,7 +60,8 @@ async def test_bridge_import_flow(hass: HomeAssistant) -> None: ) as mock_setup_entry, patch( "homeassistant.components.lutron_caseta.async_setup", return_value=True ), patch.object( - Smartbridge, "create_tls" + Smartbridge, + "create_tls", ) as create_tls: create_tls.return_value = MockBridge(can_connect=True) diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index 694e9537a8c..15175dedada 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -115,7 +115,8 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ) as unload_entry, patch( "mill.Mill.fetch_heater_and_sensor_data", return_value={} ), patch( - "mill.Mill.connect", return_value=True + "mill.Mill.connect", + return_value=True, ): assert await async_setup_component(hass, "mill", {}) diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 883a94ea02e..64fbb61aac3 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -59,7 +59,8 @@ async def serial_transport_fixture( ) as transport_class, patch("mysensors.task.OTAFirmware", autospec=True), patch( "mysensors.task.load_fw", autospec=True ), patch( - "mysensors.task.Persistence", autospec=True + "mysensors.task.Persistence", + autospec=True, ) as persistence_class: persistence = persistence_class.return_value diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 0776b80a3cd..61a7bc2354d 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -97,6 +97,6 @@ def selected_platforms(platforms): ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): yield diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index e9a66cfefc8..6dcc11d31ab 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -388,7 +388,7 @@ async def test_camera_reconnect_webhook(hass: HomeAssistant, config_entry) -> No ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ) as mock_webhook: mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -482,7 +482,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post_no_data mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -522,7 +522,7 @@ async def test_camera_image_raises_exception( ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_get_image.side_effect = fake_post diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 0ece935abcb..19f83830a4e 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -25,7 +25,7 @@ async def test_entry_diagnostics( ) as mock_auth, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index e04295ae668..75b1e9e47e6 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -205,7 +205,7 @@ async def test_setup_with_cloud(hass: HomeAssistant, config_entry) -> None: ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post_request assert await async_setup_component( @@ -271,7 +271,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 83218b6d6d1..b6df9191976 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -103,7 +103,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = ( fake_post_request_no_data diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index c888381230c..47568a7d760 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -101,7 +101,8 @@ async def mock_supervisor_fixture(hass, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": {}}, ), patch.dict( - os.environ, {"SUPERVISOR_TOKEN": "123456"} + os.environ, + {"SUPERVISOR_TOKEN": "123456"}, ): yield diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 0f2c15a5e4a..ef1ac166f1e 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -210,9 +210,11 @@ async def test_options_migration(hass: HomeAssistant) -> None: "homeassistant.components.opentherm_gw.OpenThermGatewayDevice.connect_and_subscribe", return_value=True, ), patch( - "homeassistant.components.opentherm_gw.async_setup", return_value=True + "homeassistant.components.opentherm_gw.async_setup", + return_value=True, ), patch( - "pyotgw.status.StatusManager._process_updates", return_value=None + "pyotgw.status.StatusManager._process_updates", + return_value=None, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py index 171a607d200..941c80a52da 100644 --- a/tests/components/otbr/test_util.py +++ b/tests/components/otbr/test_util.py @@ -73,7 +73,7 @@ async def test_factory_reset_error_1( ) as factory_reset_mock, patch( "python_otbr_api.OTBR.delete_active_dataset" ) as delete_active_dataset_mock, pytest.raises( - HomeAssistantError + HomeAssistantError, ): await data.factory_reset() @@ -94,7 +94,7 @@ async def test_factory_reset_error_2( "python_otbr_api.OTBR.delete_active_dataset", side_effect=python_otbr_api.OTBRError, ) as delete_active_dataset_mock, pytest.raises( - HomeAssistantError + HomeAssistantError, ): await data.factory_reset() diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index cba046a2a9d..8288e7e9f70 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -189,7 +189,7 @@ async def test_create_network_fails_3( ), patch( "python_otbr_api.OTBR.create_active_dataset", ), patch( - "python_otbr_api.OTBR.factory_reset" + "python_otbr_api.OTBR.factory_reset", ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -211,7 +211,7 @@ async def test_create_network_fails_4( "python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=python_otbr_api.OTBRError, ), patch( - "python_otbr_api.OTBR.factory_reset" + "python_otbr_api.OTBR.factory_reset", ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 235596715f4..47d70727890 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -851,7 +851,7 @@ async def test_client_header_issues( ), patch( "homeassistant.components.http.current_request.get", return_value=MockRequest() ), pytest.raises( - RuntimeError + RuntimeError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 9326869b272..4744c065ede 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -367,7 +367,7 @@ async def test_service_descriptions(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.python_script.os.path.exists", return_value=True ), patch_yaml_files( - services_yaml1 + services_yaml1, ): await async_setup_component(hass, DOMAIN, {}) @@ -416,7 +416,7 @@ async def test_service_descriptions(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.python_script.os.path.exists", return_value=True ), patch_yaml_files( - services_yaml2 + services_yaml2, ): await hass.services.async_call(DOMAIN, "reload", {}, blocking=True) descriptions = await async_get_all_descriptions(hass) diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 04e423a399c..922ec7b0a5a 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -232,7 +232,8 @@ async def test_calendar_not_supported_by_device( @pytest.mark.parametrize( - "mock_insert_schedule_response", [([None])] # Disable success responses + "mock_insert_schedule_response", + [([None])], # Disable success responses ) async def test_no_schedule( hass: HomeAssistant, diff --git a/tests/components/rainmachine/conftest.py b/tests/components/rainmachine/conftest.py index 685f307d197..2697e908c94 100644 --- a/tests/components/rainmachine/conftest.py +++ b/tests/components/rainmachine/conftest.py @@ -134,7 +134,8 @@ async def setup_rainmachine_fixture(hass, client, config): ), patch( "homeassistant.components.rainmachine.config_flow.Client", return_value=client ), patch( - "homeassistant.components.rainmachine.PLATFORMS", [] + "homeassistant.components.rainmachine.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index a982eeb39be..d0ed6f15d43 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -412,17 +412,11 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( + ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch.object( + ), patch.object(core, "Events", old_db_schema.Events), patch.object( core, "StateAttributes", old_db_schema.StateAttributes - ), patch.object( - core, "EntityIDMigrationTask", core.RecorderTask - ), patch( + ), patch.object(core, "EntityIDMigrationTask", core.RecorderTask), patch( CREATE_ENGINE_TARGET, new=partial( create_engine_test_for_schema_version_postfix, diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 852419559b2..b9d0801d788 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -85,17 +85,11 @@ def db_schema_32(): recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( + ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch.object( + ), patch.object(core, "Events", old_db_schema.Events), patch.object( core, "StateAttributes", old_db_schema.StateAttributes - ), patch.object( - core, "EntityIDMigrationTask", core.RecorderTask - ), patch( + ), patch.object(core, "EntityIDMigrationTask", core.RecorderTask), patch( CREATE_ENGINE_TARGET, new=_create_engine_test ): yield diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 4faa8dc7e8a..1696c9018b4 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -244,9 +244,7 @@ async def test_purge_old_states_encounters_temporary_mysql_error( ) as sleep_mock, patch( "homeassistant.components.recorder.purge._purge_old_recorder_runs", side_effect=[mysql_exception, None], - ), patch.object( - instance.engine.dialect, "name", "mysql" - ): + ), patch.object(instance.engine.dialect, "name", "mysql"): await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, {"keep_days": 0}) await hass.async_block_till_done() await async_wait_recording_done(hass) diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index f386fd19e36..e8f9130165f 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -212,9 +212,7 @@ async def test_purge_old_states_encounters_temporary_mysql_error( ) as sleep_mock, patch( "homeassistant.components.recorder.purge._purge_old_recorder_runs", side_effect=[mysql_exception, None], - ), patch.object( - instance.engine.dialect, "name", "mysql" - ): + ), patch.object(instance.engine.dialect, "name", "mysql"): await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, {"keep_days": 0}) await hass.async_block_till_done() await async_wait_recording_done(hass) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 98f401e45d8..b11cc67707f 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -98,13 +98,9 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmp_path: Path) - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( + ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch( + ), patch.object(core, "Events", old_db_schema.Events), patch( CREATE_ENGINE_TARGET, new=_create_engine_test ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", @@ -269,13 +265,9 @@ async def test_migrate_can_resume_entity_id_post_migration( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( + ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch( + ), patch.object(core, "Events", old_db_schema.Events), patch( CREATE_ENGINE_TARGET, new=_create_engine_test ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index b371d69fe5f..323b81211d7 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2227,9 +2227,7 @@ async def test_recorder_info_migration_queue_exhausted( ), patch( "homeassistant.components.recorder.core.create_engine", new=create_engine_test, - ), patch.object( - recorder.core, "MAX_QUEUE_BACKLOG_MIN_VALUE", 1 - ), patch.object( + ), patch.object(recorder.core, "MAX_QUEUE_BACKLOG_MIN_VALUE", 1), patch.object( recorder.core, "QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY", 0 ), patch( "homeassistant.components.recorder.migration._apply_update", diff --git a/tests/components/risco/conftest.py b/tests/components/risco/conftest.py index 325e787bb4f..a8a764cd502 100644 --- a/tests/components/risco/conftest.py +++ b/tests/components/risco/conftest.py @@ -140,7 +140,7 @@ async def setup_risco_cloud(hass, cloud_config_entry, events): "homeassistant.components.risco.RiscoCloud.site_name", new_callable=PropertyMock(return_value=TEST_SITE_NAME), ), patch( - "homeassistant.components.risco.RiscoCloud.close" + "homeassistant.components.risco.RiscoCloud.close", ), patch( "homeassistant.components.risco.RiscoCloud.get_events", return_value=events, @@ -191,7 +191,7 @@ async def setup_risco_local(hass, local_config_entry): "homeassistant.components.risco.RiscoLocal.id", new_callable=PropertyMock(return_value=TEST_SITE_UUID), ), patch( - "homeassistant.components.risco.RiscoLocal.disconnect" + "homeassistant.components.risco.RiscoLocal.disconnect", ): await hass.config_entries.async_setup(local_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/risco/test_config_flow.py b/tests/components/risco/test_config_flow.py index fdb51c65dda..8207ad819b7 100644 --- a/tests/components/risco/test_config_flow.py +++ b/tests/components/risco/test_config_flow.py @@ -162,7 +162,7 @@ async def test_form_reauth(hass: HomeAssistant, cloud_config_entry) -> None: "homeassistant.components.risco.config_flow.RiscoCloud.site_name", new_callable=PropertyMock(return_value=TEST_SITE_NAME), ), patch( - "homeassistant.components.risco.config_flow.RiscoCloud.close" + "homeassistant.components.risco.config_flow.RiscoCloud.close", ), patch( "homeassistant.components.risco.async_setup_entry", return_value=True, @@ -198,7 +198,7 @@ async def test_form_reauth_with_new_username( "homeassistant.components.risco.config_flow.RiscoCloud.site_name", new_callable=PropertyMock(return_value=TEST_SITE_NAME), ), patch( - "homeassistant.components.risco.config_flow.RiscoCloud.close" + "homeassistant.components.risco.config_flow.RiscoCloud.close", ), patch( "homeassistant.components.risco.async_setup_entry", return_value=True, @@ -307,7 +307,7 @@ async def test_form_local_already_exists(hass: HomeAssistant) -> None: "homeassistant.components.risco.config_flow.RiscoLocal.id", new_callable=PropertyMock(return_value=TEST_SITE_NAME), ), patch( - "homeassistant.components.risco.config_flow.RiscoLocal.disconnect" + "homeassistant.components.risco.config_flow.RiscoLocal.disconnect", ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], TEST_LOCAL_DATA diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index b0a01137ab9..711ae203e0f 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -55,11 +55,12 @@ def bypass_api_fixture() -> None: ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" ), patch( - "roborock.api.AttributeCache.async_value" + "roborock.api.AttributeCache.async_value", ), patch( - "roborock.api.AttributeCache.value" + "roborock.api.AttributeCache.value", ), patch( - "homeassistant.components.roborock.image.MAP_SLEEP", 0 + "homeassistant.components.roborock.image.MAP_SLEEP", + 0, ): yield diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 5e8ab9311aa..874697bf777 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -45,9 +45,9 @@ async def silent_ssdp_scanner(hass): ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" ), patch( - "homeassistant.components.ssdp.Server._async_start_upnp_servers" + "homeassistant.components.ssdp.Server._async_start_upnp_servers", ), patch( - "homeassistant.components.ssdp.Server._async_stop_upnp_servers" + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", ): yield diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py index da6a68af2d1..2277c84d187 100644 --- a/tests/components/sensibo/test_button.py +++ b/tests/components/sensibo/test_button.py @@ -100,7 +100,7 @@ async def test_button_failure( "homeassistant.components.sensibo.util.SensiboClient.async_reset_filter", return_value={"status": "failure"}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( BUTTON_DOMAIN, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 530034720f2..9cf0a8972a9 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -742,7 +742,7 @@ async def test_climate_set_timer( "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", return_value={"status": "failure"}, ), pytest.raises( - MultipleInvalid + MultipleInvalid, ): await hass.services.async_call( DOMAIN, @@ -761,7 +761,7 @@ async def test_climate_set_timer( "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", return_value={"status": "failure"}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( DOMAIN, @@ -845,7 +845,7 @@ async def test_climate_pure_boost( ), patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", ), pytest.raises( - MultipleInvalid + MultipleInvalid, ): await hass.services.async_call( DOMAIN, @@ -947,7 +947,7 @@ async def test_climate_climate_react( ), patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", ), pytest.raises( - MultipleInvalid + MultipleInvalid, ): await hass.services.async_call( DOMAIN, @@ -1254,7 +1254,7 @@ async def test_climate_full_ac_state( ), patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_states", ), pytest.raises( - MultipleInvalid + MultipleInvalid, ): await hass.services.async_call( DOMAIN, diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 7d8e3731415..41a67dfbe79 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -90,7 +90,7 @@ async def test_select_set_option( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", return_value={"result": {"status": "failed"}}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( SELECT_DOMAIN, @@ -132,7 +132,7 @@ async def test_select_set_option( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", return_value={"result": {"status": "Failed", "failureReason": "No connection"}}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( SELECT_DOMAIN, diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index c6d47ceed66..e319be85c73 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -196,7 +196,7 @@ async def test_switch_command_failure( "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", return_value={"status": "failure"}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( SWITCH_DOMAIN, @@ -214,7 +214,7 @@ async def test_switch_command_failure( "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", return_value={"status": "failure"}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( SWITCH_DOMAIN, diff --git a/tests/components/simplisafe/conftest.py b/tests/components/simplisafe/conftest.py index 4b8686d7a7f..1b9f9f02cee 100644 --- a/tests/components/simplisafe/conftest.py +++ b/tests/components/simplisafe/conftest.py @@ -106,7 +106,8 @@ async def setup_simplisafe_fixture(hass, api, config): ), patch( "homeassistant.components.simplisafe.SimpliSafe._async_start_websocket_loop" ), patch( - "homeassistant.components.simplisafe.PLATFORMS", [] + "homeassistant.components.simplisafe.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/simplisafe/test_init.py b/tests/components/simplisafe/test_init.py index 617b77f7c98..cc7b2b8d2b6 100644 --- a/tests/components/simplisafe/test_init.py +++ b/tests/components/simplisafe/test_init.py @@ -34,7 +34,8 @@ async def test_base_station_migration( ), patch( "homeassistant.components.simplisafe.SimpliSafe._async_start_websocket_loop" ), patch( - "homeassistant.components.simplisafe.PLATFORMS", [] + "homeassistant.components.simplisafe.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index f6f5ab66708..8d4d7b8c3b2 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -146,9 +146,7 @@ async def test_user_local_connection_error(hass: HomeAssistant) -> None: "pysmappee.mqtt.SmappeeLocalMqtt.start_attempt", return_value=True ), patch("pysmappee.mqtt.SmappeeLocalMqtt.start", return_value=True), patch( "pysmappee.mqtt.SmappeeLocalMqtt.stop", return_value=True - ), patch( - "pysmappee.mqtt.SmappeeLocalMqtt.is_config_ready", return_value=None - ): + ), patch("pysmappee.mqtt.SmappeeLocalMqtt.is_config_ready", return_value=None): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -474,9 +472,7 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: ), patch( "pysmappee.api.SmappeeLocalApi.load_instantaneous", return_value=[{"key": "phase0ActivePower", "value": 0}], - ), patch( - "homeassistant.components.smappee.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.smappee.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -516,9 +512,7 @@ async def test_full_user_local_flow(hass: HomeAssistant) -> None: ), patch( "pysmappee.api.SmappeeLocalApi.load_instantaneous", return_value=[{"key": "phase0ActivePower", "value": 0}], - ), patch( - "homeassistant.components.smappee.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.smappee.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index cb912af1cf6..648ca12803c 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -230,9 +230,9 @@ async def silent_ssdp_scanner(hass): ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" ), patch( - "homeassistant.components.ssdp.Server._async_start_upnp_servers" + "homeassistant.components.ssdp.Server._async_start_upnp_servers", ), patch( - "homeassistant.components.ssdp.Server._async_stop_upnp_servers" + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", ): yield diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 678e8ba5034..8bed67cb15f 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -145,9 +145,7 @@ async def setup_subaru_config_entry( return_value=vehicle_status, ), patch( MOCK_API_UPDATE, - ), patch( - MOCK_API_FETCH, side_effect=fetch_effect - ): + ), patch(MOCK_API_FETCH, side_effect=fetch_effect): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py index 239777a4da3..98d413c3b96 100644 --- a/tests/components/switchbee/test_config_flow.py +++ b/tests/components/switchbee/test_config_flow.py @@ -39,9 +39,7 @@ async def test_form(hass: HomeAssistant, test_cucode_in_coordinator_data) -> Non return_value=True, ), patch( "switchbee.api.polling.CentralUnitPolling.fetch_states", return_value=None - ), patch( - "switchbee.api.polling.CentralUnitPolling._login", return_value=None - ): + ), patch("switchbee.api.polling.CentralUnitPolling._login", return_value=None): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 39ecc95d89e..ff517b8963d 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -152,7 +152,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: "systembridgeconnector.websocket_client.WebSocketClient.get_data", return_value=FIXTURE_DATA_RESPONSE, ), patch( - "systembridgeconnector.websocket_client.WebSocketClient.listen" + "systembridgeconnector.websocket_client.WebSocketClient.listen", ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, @@ -450,7 +450,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: "systembridgeconnector.websocket_client.WebSocketClient.get_data", return_value=FIXTURE_DATA_RESPONSE, ), patch( - "systembridgeconnector.websocket_client.WebSocketClient.listen" + "systembridgeconnector.websocket_client.WebSocketClient.listen", ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, @@ -484,7 +484,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: "systembridgeconnector.websocket_client.WebSocketClient.get_data", return_value=FIXTURE_DATA_RESPONSE, ), patch( - "systembridgeconnector.websocket_client.WebSocketClient.listen" + "systembridgeconnector.websocket_client.WebSocketClient.listen", ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 87176c57692..db166144925 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -143,9 +143,9 @@ async def silent_ssdp_scanner(hass): ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" ), patch( - "homeassistant.components.ssdp.Server._async_start_upnp_servers" + "homeassistant.components.ssdp.Server._async_start_upnp_servers", ), patch( - "homeassistant.components.ssdp.Server._async_stop_upnp_servers" + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", ): yield diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index e7c878b6f40..a1637f62b01 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -94,9 +94,7 @@ async def test_observer_discovery( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -145,9 +143,7 @@ async def test_removal_by_observer_before_started( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch( - "pyudev.MonitorObserver", new=_create_mock_monitor_observer - ), patch.object( + ), patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -184,9 +180,7 @@ async def test_discovered_by_websocket_scan( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -224,9 +218,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -265,9 +257,7 @@ async def test_most_targeted_matcher_wins( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -305,9 +295,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -349,9 +337,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -389,9 +375,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -433,9 +417,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -478,9 +460,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -517,9 +497,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -554,9 +532,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -592,9 +568,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -629,9 +603,7 @@ async def test_discovered_by_websocket_no_vid_pid( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -667,9 +639,7 @@ async def test_non_matching_discovered_by_scanner_after_started( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -708,9 +678,7 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( "pyudev.Monitor.filter_by", side_effect=ValueError ), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -760,9 +728,7 @@ async def test_not_discovered_by_observer_before_started_on_docker( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch( - "pyudev.MonitorObserver", new=_create_mock_monitor_observer - ): + ), patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer): assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() @@ -1047,9 +1013,7 @@ async def test_resolve_serial_by_id( ), patch( "homeassistant.components.usb.get_serial_by_id", return_value="/dev/serial/by-id/bla", - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/vilfo/test_config_flow.py b/tests/components/vilfo/test_config_flow.py index 0aa59c9271f..b893d2df550 100644 --- a/tests/components/vilfo/test_config_flow.py +++ b/tests/components/vilfo/test_config_flow.py @@ -24,9 +24,7 @@ async def test_form(hass: HomeAssistant) -> None: "vilfo.Client.get_board_information", return_value=None ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=mock_mac - ), patch( + ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac), patch( "homeassistant.components.vilfo.async_setup_entry" ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( @@ -117,9 +115,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: return_value=None, ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=None - ): + ), patch("vilfo.Client.resolve_mac_address", return_value=None): first_flow_result2 = await hass.config_entries.flow.async_configure( first_flow_result1["flow_id"], {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, @@ -134,9 +130,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: return_value=None, ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=None - ): + ), patch("vilfo.Client.resolve_mac_address", return_value=None): second_flow_result2 = await hass.config_entries.flow.async_configure( second_flow_result1["flow_id"], {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, @@ -177,9 +171,7 @@ async def test_validate_input_returns_data(hass: HomeAssistant) -> None: "vilfo.Client.get_board_information", return_value=None ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=None - ): + ), patch("vilfo.Client.resolve_mac_address", return_value=None): result = await hass.components.vilfo.config_flow.validate_input( hass, data=mock_data ) @@ -193,9 +185,7 @@ async def test_validate_input_returns_data(hass: HomeAssistant) -> None: "vilfo.Client.get_board_information", return_value=None ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=mock_mac - ): + ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac): result2 = await hass.components.vilfo.config_flow.validate_input( hass, data=mock_data ) diff --git a/tests/components/vlc_telnet/test_config_flow.py b/tests/components/vlc_telnet/test_config_flow.py index 91ea5b3e439..a94f290f7e6 100644 --- a/tests/components/vlc_telnet/test_config_flow.py +++ b/tests/components/vlc_telnet/test_config_flow.py @@ -124,7 +124,7 @@ async def test_errors( "homeassistant.components.vlc_telnet.config_flow.Client.login", side_effect=login_side_effect, ), patch( - "homeassistant.components.vlc_telnet.config_flow.Client.disconnect" + "homeassistant.components.vlc_telnet.config_flow.Client.disconnect", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -219,7 +219,7 @@ async def test_reauth_errors( "homeassistant.components.vlc_telnet.config_flow.Client.login", side_effect=login_side_effect, ), patch( - "homeassistant.components.vlc_telnet.config_flow.Client.disconnect" + "homeassistant.components.vlc_telnet.config_flow.Client.disconnect", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -316,7 +316,7 @@ async def test_hassio_errors( "homeassistant.components.vlc_telnet.config_flow.Client.login", side_effect=login_side_effect, ), patch( - "homeassistant.components.vlc_telnet.config_flow.Client.disconnect" + "homeassistant.components.vlc_telnet.config_flow.Client.disconnect", ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 982a14a80f4..00b1ae6e72a 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -24,7 +24,7 @@ async def test_user(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ) as mock_setup_entry, patch( - "requests.get" + "requests.get", ) as mock_request_get: mock_request_get.return_value.status_code = 200 @@ -90,7 +90,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> ), patch( "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( - "homeassistant.components.vodafone_station.async_setup_entry" + "homeassistant.components.vodafone_station.async_setup_entry", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -122,9 +122,9 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( - "homeassistant.components.vodafone_station.async_setup_entry" + "homeassistant.components.vodafone_station.async_setup_entry", ), patch( - "requests.get" + "requests.get", ) as mock_request_get: mock_request_get.return_value.status_code = 200 @@ -170,7 +170,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> ), patch( "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( - "homeassistant.components.vodafone_station.async_setup_entry" + "homeassistant.components.vodafone_station.async_setup_entry", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -204,7 +204,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> ), patch( "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( - "homeassistant.components.vodafone_station.async_setup_entry" + "homeassistant.components.vodafone_station.async_setup_entry", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index 7a95e000d82..ecc7e07158d 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -235,9 +235,9 @@ async def test_error_in_second_step( with patch( "aiowaqi.WAQIClient.authenticate", - ), patch( - "aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception - ), patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception): + ), patch("aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception), patch( + "aiowaqi.WAQIClient.get_by_station_number", side_effect=exception + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], payload, diff --git a/tests/components/watttime/conftest.py b/tests/components/watttime/conftest.py index f3c1986fcb0..f636ffefcfb 100644 --- a/tests/components/watttime/conftest.py +++ b/tests/components/watttime/conftest.py @@ -106,9 +106,7 @@ async def setup_watttime_fixture(hass, client, config_auth, config_coordinates): ), patch( "homeassistant.components.watttime.config_flow.Client.async_login", return_value=client, - ), patch( - "homeassistant.components.watttime.PLATFORMS", [] - ): + ), patch("homeassistant.components.watttime.PLATFORMS", []): assert await async_setup_component( hass, DOMAIN, {**config_auth, **config_coordinates} ) diff --git a/tests/components/withings/test_diagnostics.py b/tests/components/withings/test_diagnostics.py index bb5c93e1f09..928eccdde0f 100644 --- a/tests/components/withings/test_diagnostics.py +++ b/tests/components/withings/test_diagnostics.py @@ -67,9 +67,9 @@ async def test_diagnostics_cloudhook_instance( ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.cloud.async_delete_cloudhook" + "homeassistant.components.cloud.async_delete_cloudhook", ), patch( - "homeassistant.components.withings.webhook_generate_url" + "homeassistant.components.withings.webhook_generate_url", ): await setup_integration(hass, webhook_config_entry) await prepare_webhook_setup(hass, freezer) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 3f20791ac4d..390fbc3bbc3 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -352,7 +352,7 @@ async def test_removing_entry_with_cloud_unavailable( "homeassistant.components.cloud.async_delete_cloudhook", side_effect=CloudNotAvailable(), ), patch( - "homeassistant.components.withings.webhook_generate_url" + "homeassistant.components.withings.webhook_generate_url", ): await setup_integration(hass, cloudhook_config_entry) assert hass.components.cloud.async_active_subscription() is True @@ -469,9 +469,9 @@ async def test_cloud_disconnect( ), patch( "homeassistant.components.withings.async_get_config_entry_implementation", ), patch( - "homeassistant.components.cloud.async_delete_cloudhook" + "homeassistant.components.cloud.async_delete_cloudhook", ), patch( - "homeassistant.components.withings.webhook_generate_url" + "homeassistant.components.withings.webhook_generate_url", ): await setup_integration(hass, webhook_config_entry) await prepare_webhook_setup(hass, freezer) diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 68b7b2b62bc..2f2a25558e4 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -180,7 +180,7 @@ async def test_get_tts_audio_audio_oserror( ), patch.object( mock_client, "read_event", side_effect=OSError("Boom!") ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await tts.async_get_media_source_audio( hass, diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index ccccd98b3b6..4ce95e418d0 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -22,9 +22,9 @@ async def silent_ssdp_scanner(hass): ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" ), patch( - "homeassistant.components.ssdp.Server._async_start_upnp_servers" + "homeassistant.components.ssdp.Server._async_start_upnp_servers", ), patch( - "homeassistant.components.ssdp.Server._async_stop_upnp_servers" + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", ): yield diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 0bd5b5f59d0..e1d33ee5f75 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -440,9 +440,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb ), patch( - f"{MODULE}.async_setup", return_value=True + f"{MODULE}.async_setup", + return_value=True, ), patch( - f"{MODULE}.async_setup_entry", return_value=True + f"{MODULE}.async_setup_entry", + return_value=True, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index ba0bbbe087d..f9615c84e1d 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -158,15 +158,13 @@ async def test_if_notification_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.notification.notification - device - zwave_js_notification - {}".format( - CommandClass.NOTIFICATION + assert ( + calls[0].data["some"] + == f"event.notification.notification - device - zwave_js_notification - {CommandClass.NOTIFICATION}" ) - assert calls[1].data[ - "some" - ] == "event.notification.notification2 - device - zwave_js_notification - {}".format( - CommandClass.NOTIFICATION + assert ( + calls[1].data["some"] + == f"event.notification.notification2 - device - zwave_js_notification - {CommandClass.NOTIFICATION}" ) @@ -288,15 +286,13 @@ async def test_if_entry_control_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.notification.notification - device - zwave_js_notification - {}".format( - CommandClass.ENTRY_CONTROL + assert ( + calls[0].data["some"] + == f"event.notification.notification - device - zwave_js_notification - {CommandClass.ENTRY_CONTROL}" ) - assert calls[1].data[ - "some" - ] == "event.notification.notification2 - device - zwave_js_notification - {}".format( - CommandClass.ENTRY_CONTROL + assert ( + calls[1].data["some"] + == f"event.notification.notification2 - device - zwave_js_notification - {CommandClass.ENTRY_CONTROL}" ) @@ -705,15 +701,13 @@ async def test_if_basic_value_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.value_notification.basic - device - zwave_js_value_notification - {}".format( - CommandClass.BASIC + assert ( + calls[0].data["some"] + == f"event.value_notification.basic - device - zwave_js_value_notification - {CommandClass.BASIC}" ) - assert calls[1].data[ - "some" - ] == "event.value_notification.basic2 - device - zwave_js_value_notification - {}".format( - CommandClass.BASIC + assert ( + calls[1].data["some"] + == f"event.value_notification.basic2 - device - zwave_js_value_notification - {CommandClass.BASIC}" ) @@ -888,15 +882,13 @@ async def test_if_central_scene_value_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.value_notification.central_scene - device - zwave_js_value_notification - {}".format( - CommandClass.CENTRAL_SCENE + assert ( + calls[0].data["some"] + == f"event.value_notification.central_scene - device - zwave_js_value_notification - {CommandClass.CENTRAL_SCENE}" ) - assert calls[1].data[ - "some" - ] == "event.value_notification.central_scene2 - device - zwave_js_value_notification - {}".format( - CommandClass.CENTRAL_SCENE + assert ( + calls[1].data["some"] + == f"event.value_notification.central_scene2 - device - zwave_js_value_notification - {CommandClass.CENTRAL_SCENE}" ) @@ -1064,15 +1056,13 @@ async def test_if_scene_activation_value_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.value_notification.scene_activation - device - zwave_js_value_notification - {}".format( - CommandClass.SCENE_ACTIVATION + assert ( + calls[0].data["some"] + == f"event.value_notification.scene_activation - device - zwave_js_value_notification - {CommandClass.SCENE_ACTIVATION}" ) - assert calls[1].data[ - "some" - ] == "event.value_notification.scene_activation2 - device - zwave_js_value_notification - {}".format( - CommandClass.SCENE_ACTIVATION + assert ( + calls[1].data["some"] + == f"event.value_notification.scene_activation2 - device - zwave_js_value_notification - {CommandClass.SCENE_ACTIVATION}" ) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 5e5343fd43e..b65f09aeaf9 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -416,9 +416,7 @@ automation: service_to_call: test.automation input_datetime: """, - hass.config.path( - "blueprints/automation/test_event_service.yaml" - ): """ + hass.config.path("blueprints/automation/test_event_service.yaml"): """ blueprint: name: "Call service based on event" domain: automation diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index ebb0cc35c20..5c3697ad936 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -38,13 +38,9 @@ async def test_get_system_info_supervisor_not_available( "homeassistant.helpers.system_info.is_docker_env", return_value=True ), patch( "homeassistant.helpers.system_info.is_official_image", return_value=True - ), patch( - "homeassistant.components.hassio.is_hassio", return_value=True - ), patch( + ), patch("homeassistant.components.hassio.is_hassio", return_value=True), patch( "homeassistant.components.hassio.get_info", return_value=None - ), patch( - "homeassistant.helpers.system_info.cached_get_user", return_value="root" - ): + ), patch("homeassistant.helpers.system_info.cached_get_user", return_value="root"): info = await async_get_system_info(hass) assert isinstance(info, dict) assert info["version"] == current_version @@ -60,9 +56,7 @@ async def test_get_system_info_supervisor_not_loaded(hass: HomeAssistant) -> Non "homeassistant.helpers.system_info.is_docker_env", return_value=True ), patch( "homeassistant.helpers.system_info.is_official_image", return_value=True - ), patch( - "homeassistant.components.hassio.get_info", return_value=None - ), patch.dict( + ), patch("homeassistant.components.hassio.get_info", return_value=None), patch.dict( os.environ, {"SUPERVISOR": "127.0.0.1"} ): info = await async_get_system_info(hass) @@ -79,9 +73,7 @@ async def test_container_installationtype(hass: HomeAssistant) -> None: "homeassistant.helpers.system_info.is_docker_env", return_value=True ), patch( "homeassistant.helpers.system_info.is_official_image", return_value=True - ), patch( - "homeassistant.helpers.system_info.cached_get_user", return_value="root" - ): + ), patch("homeassistant.helpers.system_info.cached_get_user", return_value="root"): info = await async_get_system_info(hass) assert info["installation_type"] == "Home Assistant Container" @@ -89,9 +81,7 @@ async def test_container_installationtype(hass: HomeAssistant) -> None: "homeassistant.helpers.system_info.is_docker_env", return_value=True ), patch( "homeassistant.helpers.system_info.is_official_image", return_value=False - ), patch( - "homeassistant.helpers.system_info.cached_get_user", return_value="user" - ): + ), patch("homeassistant.helpers.system_info.cached_get_user", return_value="user"): info = await async_get_system_info(hass) assert info["installation_type"] == "Unsupported Third Party Container" diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 4fa10b92706..fd01beed9ab 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -31,9 +31,7 @@ async def test_requirement_installed_in_venv(hass: HomeAssistant) -> None: "homeassistant.util.package.is_virtual_env", return_value=True ), patch("homeassistant.util.package.is_docker_env", return_value=False), patch( "homeassistant.util.package.install_package", return_value=True - ) as mock_install, patch.dict( - os.environ, env_without_wheel_links(), clear=True - ): + ) as mock_install, patch.dict(os.environ, env_without_wheel_links(), clear=True): hass.config.skip_pip = False mock_integration(hass, MockModule("comp", requirements=["package==0.0.1"])) assert await setup.async_setup_component(hass, "comp", {}) @@ -51,9 +49,7 @@ async def test_requirement_installed_in_deps(hass: HomeAssistant) -> None: "homeassistant.util.package.is_virtual_env", return_value=False ), patch("homeassistant.util.package.is_docker_env", return_value=False), patch( "homeassistant.util.package.install_package", return_value=True - ) as mock_install, patch.dict( - os.environ, env_without_wheel_links(), clear=True - ): + ) as mock_install, patch.dict(os.environ, env_without_wheel_links(), clear=True): hass.config.skip_pip = False mock_integration(hass, MockModule("comp", requirements=["package==0.0.1"])) assert await setup.async_setup_component(hass, "comp", {}) @@ -369,7 +365,7 @@ async def test_install_with_wheels_index(hass: HomeAssistant) -> None: ), patch("homeassistant.util.package.install_package") as mock_inst, patch.dict( os.environ, {"WHEELS_LINKS": "https://wheels.hass.io/test"} ), patch( - "os.path.dirname" + "os.path.dirname", ) as mock_dir: mock_dir.return_value = "ha_package_path" assert await setup.async_setup_component(hass, "comp", {}) @@ -391,9 +387,7 @@ async def test_install_on_docker(hass: HomeAssistant) -> None: "homeassistant.util.package.is_docker_env", return_value=True ), patch("homeassistant.util.package.install_package") as mock_inst, patch( "os.path.dirname" - ) as mock_dir, patch.dict( - os.environ, env_without_wheel_links(), clear=True - ): + ) as mock_dir, patch.dict(os.environ, env_without_wheel_links(), clear=True): mock_dir.return_value = "ha_package_path" assert await setup.async_setup_component(hass, "comp", {}) assert "comp" in hass.config.components diff --git a/tests/test_runner.py b/tests/test_runner.py index 5fe5c2881ff..3b06e3b64dc 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -75,7 +75,7 @@ def test_run_executor_shutdown_throws( "homeassistant.runner.InterruptibleThreadPoolExecutor.shutdown", side_effect=RuntimeError, ) as mock_shutdown, patch( - "homeassistant.core.HomeAssistant.async_run" + "homeassistant.core.HomeAssistant.async_run", ) as mock_run: runner.run(default_config) From a5934e9acc513a37c3718889bb6a15a0cef12521 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 27 Nov 2023 15:35:43 +0100 Subject: [PATCH 790/982] Handle preset change errors in ViCare integration (#103992) --- homeassistant/components/vicare/climate.py | 46 +++++++++++++++----- homeassistant/components/vicare/strings.json | 11 +++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index b32b6e28480..a7a2fb7e277 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -292,22 +293,45 @@ class ViCareClimate(ViCareEntity, ClimateEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode and deactivate any existing programs.""" - vicare_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) - if vicare_program is None: - raise ValueError( - f"Cannot set invalid vicare program: {preset_mode}/{vicare_program}" + target_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) + if target_program is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="program_unknown", + translation_placeholders={ + "preset": preset_mode, + }, ) - _LOGGER.debug("Setting preset to %s / %s", preset_mode, vicare_program) - if self._current_program != VICARE_PROGRAM_NORMAL: + _LOGGER.debug("Current preset %s", self._current_program) + if self._current_program and self._current_program != VICARE_PROGRAM_NORMAL: # We can't deactivate "normal" + _LOGGER.debug("deactivating %s", self._current_program) try: self._circuit.deactivateProgram(self._current_program) - except PyViCareCommandError: - _LOGGER.debug("Unable to deactivate program %s", self._current_program) - if vicare_program != VICARE_PROGRAM_NORMAL: - # And we can't explicitly activate normal, either - self._circuit.activateProgram(vicare_program) + except PyViCareCommandError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="program_not_deactivated", + translation_placeholders={ + "program": self._current_program, + }, + ) from err + + _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) + if target_program != VICARE_PROGRAM_NORMAL: + # And we can't explicitly activate "normal", either + _LOGGER.debug("activating %s", target_program) + try: + self._circuit.activateProgram(target_program) + except PyViCareCommandError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="program_not_activated", + translation_placeholders={ + "program": target_program, + }, + ) from err @property def extra_state_attributes(self): diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index f3a51bde9e4..e9ee272edd8 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -288,6 +288,17 @@ } } }, + "exceptions": { + "program_unknown": { + "message": "Cannot translate preset {preset} into a valid ViCare program" + }, + "program_not_activated": { + "message": "Unable to activate ViCare program {program}" + }, + "program_not_deactivated": { + "message": "Unable to deactivate ViCare program {program}" + } + }, "services": { "set_vicare_mode": { "name": "Set ViCare mode", From 74d7d0283317f19200ba6a25f2ef1ab60dcc94fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Nov 2023 09:04:04 -0600 Subject: [PATCH 791/982] Bump aiohttp-fast-url-dispatcher to 0.3.0 (#104592) --- homeassistant/components/http/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index f2f8b51665a..c68ecd79d5f 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "aiohttp_cors==0.7.0", - "aiohttp-fast-url-dispatcher==0.1.0", + "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.1.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d338d8a8d9a..bae4d616903 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ # Automatically generated by gen_requirements_all.py, do not edit aiodiscover==1.5.1 -aiohttp-fast-url-dispatcher==0.1.0 +aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 aiohttp==3.8.5;python_version<'3.12' aiohttp==3.9.0;python_version>='3.12' diff --git a/pyproject.toml b/pyproject.toml index 7b822bd7a7d..888743c1d6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.9.0;python_version>='3.12'", "aiohttp==3.8.5;python_version<'3.12'", "aiohttp_cors==0.7.0", - "aiohttp-fast-url-dispatcher==0.1.0", + "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.1.1", "astral==2.2", "attrs==23.1.0", diff --git a/requirements.txt b/requirements.txt index 6c10af6f2ad..1d1837d9bce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiohttp==3.9.0;python_version>='3.12' aiohttp==3.8.5;python_version<'3.12' aiohttp_cors==0.7.0 -aiohttp-fast-url-dispatcher==0.1.0 +aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 astral==2.2 attrs==23.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4ec59bce2e1..61fed306468 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,7 +257,7 @@ aioharmony==0.2.10 aiohomekit==3.0.9 # homeassistant.components.http -aiohttp-fast-url-dispatcher==0.1.0 +aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http aiohttp-zlib-ng==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3203a8e87ce..4c6281c0007 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioharmony==0.2.10 aiohomekit==3.0.9 # homeassistant.components.http -aiohttp-fast-url-dispatcher==0.1.0 +aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http aiohttp-zlib-ng==0.1.1 From 664aca2c68e3c9857ec0f21efc51555bcec6b417 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Nov 2023 07:43:03 -0800 Subject: [PATCH 792/982] Fix rainbird duplicate devices (#104528) * Repair duplicate devices added to the rainbird integration * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Update tests/components/rainbird/test_init.py * Remove use of config_entry.async_setup --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/rainbird/__init__.py | 76 +++++++- tests/components/rainbird/test_init.py | 174 ++++++++++++++++-- 2 files changed, 233 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index e7a7c1200b9..c149c993acb 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -10,10 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity_registry import async_entries_for_config_entry from .const import CONF_SERIAL_NUMBER from .coordinator import RainbirdData @@ -55,6 +54,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: format_mac(mac_address), str(entry.data[CONF_SERIAL_NUMBER]), ) + _async_fix_device_id( + hass, + dr.async_get(hass), + entry.entry_id, + format_mac(mac_address), + str(entry.data[CONF_SERIAL_NUMBER]), + ) try: model_info = await controller.get_model_and_version() @@ -124,7 +130,7 @@ def _async_fix_entity_unique_id( serial_number: str, ) -> None: """Migrate existing entity if current one can't be found and an old one exists.""" - entity_entries = async_entries_for_config_entry(entity_registry, config_entry_id) + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) for entity_entry in entity_entries: unique_id = str(entity_entry.unique_id) if unique_id.startswith(mac_address): @@ -137,6 +143,70 @@ def _async_fix_entity_unique_id( ) +def _async_device_entry_to_keep( + old_entry: dr.DeviceEntry, new_entry: dr.DeviceEntry +) -> dr.DeviceEntry: + """Determine which device entry to keep when there are duplicates. + + As we transitioned to new unique ids, we did not update existing device entries + and as a result there are devices with both the old and new unique id format. We + have to pick which one to keep, and preferably this can repair things if the + user previously renamed devices. + """ + # Prefer the new device if the user already gave it a name or area. Otherwise, + # do the same for the old entry. If no entries have been modified then keep the new one. + if new_entry.disabled_by is None and ( + new_entry.area_id is not None or new_entry.name_by_user is not None + ): + return new_entry + if old_entry.disabled_by is None and ( + old_entry.area_id is not None or old_entry.name_by_user is not None + ): + return old_entry + return new_entry if new_entry.disabled_by is None else old_entry + + +def _async_fix_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry_id: str, + mac_address: str, + serial_number: str, +) -> None: + """Migrate existing device identifiers to the new format. + + This will rename any device ids that are prefixed with the serial number to be prefixed + with the mac address. This also cleans up from a bug that allowed devices to exist + in both the old and new format. + """ + device_entries = dr.async_entries_for_config_entry(device_registry, config_entry_id) + device_entry_map = {} + migrations = {} + for device_entry in device_entries: + unique_id = next(iter(device_entry.identifiers))[1] + device_entry_map[unique_id] = device_entry + if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: + migrations[unique_id] = f"{mac_address}{suffix}" + + for unique_id, new_unique_id in migrations.items(): + old_entry = device_entry_map[unique_id] + if (new_entry := device_entry_map.get(new_unique_id)) is not None: + # Device entries exist for both the old and new format and one must be removed + entry_to_keep = _async_device_entry_to_keep(old_entry, new_entry) + if entry_to_keep == new_entry: + _LOGGER.debug("Removing device entry %s", unique_id) + device_registry.async_remove_device(old_entry.id) + continue + # Remove new entry and update old entry to new id below + _LOGGER.debug("Removing device entry %s", new_unique_id) + device_registry.async_remove_device(new_entry.id) + + _LOGGER.debug("Updating device id from %s to %s", unique_id, new_unique_id) + device_registry.async_update_device( + old_entry.id, new_identifiers={(DOMAIN, new_unique_id)} + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index db9c4c8739e..7048e1d63f4 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations from http import HTTPStatus +from typing import Any import pytest @@ -10,7 +11,7 @@ from homeassistant.components.rainbird.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( CONFIG_ENTRY_DATA, @@ -35,7 +36,7 @@ async def test_init_success( ) -> None: """Test successful setup and unload.""" - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == ConfigEntryState.LOADED await hass.config_entries.async_unload(config_entry.entry_id) @@ -86,7 +87,7 @@ async def test_communication_failure( config_entry_state: list[ConfigEntryState], ) -> None: """Test unable to talk to device on startup, which fails setup.""" - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == config_entry_state @@ -115,7 +116,7 @@ async def test_fix_unique_id( assert entries[0].unique_id is None assert entries[0].data.get(CONF_MAC) is None - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == ConfigEntryState.LOADED # Verify config entry now has a unique id @@ -167,7 +168,7 @@ async def test_fix_unique_id_failure( responses.insert(0, initial_response) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) # Config entry is loaded, but not updated assert config_entry.state == ConfigEntryState.LOADED assert config_entry.unique_id is None @@ -202,14 +203,10 @@ async def test_fix_unique_id_duplicate( responses.append(mock_json_response(WIFI_PARAMS_RESPONSE)) responses.extend(responses_copy) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == ConfigEntryState.LOADED assert config_entry.unique_id == MAC_ADDRESS_UNIQUE_ID - await other_entry.async_setup(hass) - # Config entry unique id could not be updated since it already exists - assert other_entry.state == ConfigEntryState.SETUP_ERROR - assert "Unable to fix missing unique id (already exists)" in caplog.text await hass.async_block_till_done() @@ -221,34 +218,51 @@ async def test_fix_unique_id_duplicate( "config_entry_unique_id", "serial_number", "entity_unique_id", + "device_identifier", "expected_unique_id", + "expected_device_identifier", ), [ - (SERIAL_NUMBER, SERIAL_NUMBER, SERIAL_NUMBER, MAC_ADDRESS_UNIQUE_ID), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + str(SERIAL_NUMBER), + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), ( SERIAL_NUMBER, SERIAL_NUMBER, f"{SERIAL_NUMBER}-rain-delay", + f"{SERIAL_NUMBER}-1", f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-1", ), - ("0", 0, "0", MAC_ADDRESS_UNIQUE_ID), + ("0", 0, "0", "0", MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID), ( "0", 0, "0-rain-delay", + "0-1", f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-1", ), ( MAC_ADDRESS_UNIQUE_ID, SERIAL_NUMBER, MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, ), ( MAC_ADDRESS_UNIQUE_ID, SERIAL_NUMBER, f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-1", f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-1", ), ], ids=( @@ -264,18 +278,150 @@ async def test_fix_entity_unique_ids( hass: HomeAssistant, config_entry: MockConfigEntry, entity_unique_id: str, + device_identifier: str, expected_unique_id: str, + expected_device_identifier: str, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test fixing entity unique ids from old unique id formats.""" - entity_registry = er.async_get(hass) entity_entry = entity_registry.async_get_or_create( DOMAIN, "number", unique_id=entity_unique_id, config_entry=config_entry ) + device_entry = device_registry.async_get_or_create( + identifiers={(DOMAIN, device_identifier)}, + config_entry_id=config_entry.entry_id, + serial_number=config_entry.data["serial_number"], + ) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == ConfigEntryState.LOADED entity_entry = entity_registry.async_get(entity_entry.id) assert entity_entry assert entity_entry.unique_id == expected_unique_id + + device_entry = device_registry.async_get_device( + {(DOMAIN, expected_device_identifier)} + ) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, expected_device_identifier)} + + +@pytest.mark.parametrize( + ( + "entry1_updates", + "entry2_updates", + "expected_device_name", + "expected_disabled_by", + ), + [ + ({}, {}, None, None), + ( + { + "name_by_user": "Front Sprinkler", + }, + {}, + "Front Sprinkler", + None, + ), + ( + {}, + { + "name_by_user": "Front Sprinkler", + }, + "Front Sprinkler", + None, + ), + ( + { + "name_by_user": "Sprinkler 1", + }, + { + "name_by_user": "Sprinkler 2", + }, + "Sprinkler 2", + None, + ), + ( + { + "disabled_by": dr.DeviceEntryDisabler.USER, + }, + {}, + None, + None, + ), + ( + {}, + { + "disabled_by": dr.DeviceEntryDisabler.USER, + }, + None, + None, + ), + ( + { + "disabled_by": dr.DeviceEntryDisabler.USER, + }, + { + "disabled_by": dr.DeviceEntryDisabler.USER, + }, + None, + dr.DeviceEntryDisabler.USER, + ), + ], + ids=[ + "duplicates", + "prefer-old-name", + "prefer-new-name", + "both-names-prefers-new", + "old-disabled-prefer-new", + "new-disabled-prefer-old", + "both-disabled", + ], +) +async def test_fix_duplicate_device_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entry1_updates: dict[str, Any], + entry2_updates: dict[str, Any], + expected_device_name: str | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, +) -> None: + """Test fixing duplicate device ids.""" + + entry1 = device_registry.async_get_or_create( + identifiers={(DOMAIN, str(SERIAL_NUMBER))}, + config_entry_id=config_entry.entry_id, + serial_number=config_entry.data["serial_number"], + ) + device_registry.async_update_device(entry1.id, **entry1_updates) + + entry2 = device_registry.async_get_or_create( + identifiers={(DOMAIN, MAC_ADDRESS_UNIQUE_ID)}, + config_entry_id=config_entry.entry_id, + serial_number=config_entry.data["serial_number"], + ) + device_registry.async_update_device(entry2.id, **entry2_updates) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entries) == 2 + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.LOADED + + # Only the device with the new format exists + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entries) == 1 + + device_entry = device_registry.async_get_device({(DOMAIN, MAC_ADDRESS_UNIQUE_ID)}) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, MAC_ADDRESS_UNIQUE_ID)} + assert device_entry.name_by_user == expected_device_name + assert device_entry.disabled_by == expected_disabled_by From 239d7c9d8099e836ab4dfe469b8929e2e0f31aef Mon Sep 17 00:00:00 2001 From: Pascal Reeb Date: Mon, 27 Nov 2023 17:28:13 +0100 Subject: [PATCH 793/982] Enable the use of non-encrypted token in Nuki (#104007) --- homeassistant/components/nuki/__init__.py | 4 ++-- homeassistant/components/nuki/config_flow.py | 24 ++++++++++++++++---- homeassistant/components/nuki/const.py | 3 +++ homeassistant/components/nuki/strings.json | 6 +++-- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index ede7a20ccdb..3f17c0b795b 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -39,7 +39,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DEFAULT_TIMEOUT, DOMAIN, ERROR_STATES +from .const import CONF_ENCRYPT_TOKEN, DEFAULT_TIMEOUT, DOMAIN, ERROR_STATES from .helpers import NukiWebhookException, parse_id _NukiDeviceT = TypeVar("_NukiDeviceT", bound=NukiDevice) @@ -188,7 +188,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_HOST], entry.data[CONF_TOKEN], entry.data[CONF_PORT], - True, + entry.data.get(CONF_ENCRYPT_TOKEN, True), DEFAULT_TIMEOUT, ) diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 310197d55d8..4acfecf492b 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult -from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_ENCRYPT_TOKEN, DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN from .helpers import CannotConnect, InvalidAuth, parse_id _LOGGER = logging.getLogger(__name__) @@ -26,7 +26,12 @@ USER_SCHEMA = vol.Schema( } ) -REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_TOKEN): str}) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + vol.Optional(CONF_ENCRYPT_TOKEN, default=True): bool, + } +) async def validate_input(hass, data): @@ -41,7 +46,7 @@ async def validate_input(hass, data): data[CONF_HOST], data[CONF_TOKEN], data[CONF_PORT], - True, + data.get(CONF_ENCRYPT_TOKEN, True), DEFAULT_TIMEOUT, ) @@ -100,6 +105,7 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_HOST: self._data[CONF_HOST], CONF_PORT: self._data[CONF_PORT], CONF_TOKEN: user_input[CONF_TOKEN], + CONF_ENCRYPT_TOKEN: user_input[CONF_ENCRYPT_TOKEN], } try: @@ -131,8 +137,15 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_validate(self, user_input=None): """Handle init step of a flow.""" + data_schema = self.discovery_schema or USER_SCHEMA + errors = {} if user_input is not None: + data_schema = USER_SCHEMA.extend( + { + vol.Optional(CONF_ENCRYPT_TOKEN, default=True): bool, + } + ) try: info = await validate_input(self.hass, user_input) except CannotConnect: @@ -149,7 +162,8 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=bridge_id, data=user_input) - data_schema = self.discovery_schema or USER_SCHEMA return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema(data_schema, user_input), + errors=errors, ) diff --git a/homeassistant/components/nuki/const.py b/homeassistant/components/nuki/const.py index dee4a8b8ac5..21a2dcf9e5b 100644 --- a/homeassistant/components/nuki/const.py +++ b/homeassistant/components/nuki/const.py @@ -12,3 +12,6 @@ DEFAULT_PORT = 8080 DEFAULT_TIMEOUT = 20 ERROR_STATES = (0, 254, 255) + +# Encrypt token, instead of using a plaintext token +CONF_ENCRYPT_TOKEN = "encrypt_token" diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index 19aeae989f4..eb380cabd04 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -5,14 +5,16 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", - "token": "[%key:common::config_flow::data::access_token%]" + "token": "[%key:common::config_flow::data::access_token%]", + "encrypt_token": "Use an encrypted token for authentication." } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Nuki integration needs to re-authenticate with your bridge.", "data": { - "token": "[%key:common::config_flow::data::access_token%]" + "token": "[%key:common::config_flow::data::access_token%]", + "encrypt_token": "[%key:component::nuki::config::step::user::data::encrypt_token%]" } } }, From 2a4ab3d53d5a7bc1756fb3927d503b13fec2bee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 27 Nov 2023 19:03:29 +0200 Subject: [PATCH 794/982] Support HTTPS connections to Huawei LTE devices (#86119) * Support HTTPS connections to Huawei LTE devices Not all devices support HTTPS, so we default to plain HTTP still. Ones that do are very likely to have certificates that do not pass hostname verification, and are either self signed or issued by an untrusted CA. Add option to use unverified HTTPS to make it possible to connect to these, and when in effect, filter urllib3's related warnings about insecure connections to the hostname in question. * Use common config key and strings for certificate verification settings Even though the wording might be slightly suboptimal here, it's better to be consistent across the codebase than to finetune on this level. This also switches the default the other way around: verification is now disabled by default. This is not a good general default, but for this particular case setups where the verification would succeed would be so rare and require considerable local setup that it's very unlikely to happen in practice. * Add config flow tests * Mock logout for better test coverage * Set up custom requests session only when using unverified https * Add https config flow test case * Make better use of verify SSL default --- .../components/huawei_lte/__init__.py | 14 ++- .../components/huawei_lte/config_flow.py | 28 +++++- .../components/huawei_lte/strings.json | 5 +- homeassistant/components/huawei_lte/utils.py | 20 ++++ .../components/huawei_lte/test_config_flow.py | 91 +++++++++++++------ 5 files changed, 124 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index d0d1ce71161..62efabf1f5e 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -35,6 +35,7 @@ from homeassistant.const import ( CONF_RECIPIENT, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -89,7 +90,7 @@ from .const import ( SERVICE_SUSPEND_INTEGRATION, UPDATE_SIGNAL, ) -from .utils import get_device_macs +from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) @@ -335,16 +336,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _connect() -> Connection: """Set up a connection.""" + kwargs: dict[str, Any] = { + "timeout": CONNECTION_TIMEOUT, + } + if url.startswith("https://") and not entry.data.get(CONF_VERIFY_SSL): + kwargs["requests_session"] = non_verifying_requests_session(url) if entry.options.get(CONF_UNAUTHENTICATED_MODE): _LOGGER.debug("Connecting in unauthenticated mode, reduced feature set") - connection = Connection(url, timeout=CONNECTION_TIMEOUT) + connection = Connection(url, **kwargs) else: _LOGGER.debug("Connecting in authenticated mode, full feature set") username = entry.data.get(CONF_USERNAME) or "" password = entry.data.get(CONF_PASSWORD) or "" - connection = Connection( - url, username=username, password=password, timeout=CONNECTION_TIMEOUT - ) + connection = Connection(url, username=username, password=password, **kwargs) return connection try: diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 6d7b0b9bb11..c97c8d6367b 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -16,7 +16,7 @@ from huawei_lte_api.exceptions import ( ResponseErrorException, ) from huawei_lte_api.Session import GetResponseType -from requests.exceptions import Timeout +from requests.exceptions import SSLError, Timeout from url_normalize import url_normalize import voluptuous as vol @@ -29,6 +29,7 @@ from homeassistant.const import ( CONF_RECIPIENT, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -44,7 +45,7 @@ from .const import ( DEFAULT_UNAUTHENTICATED_MODE, DOMAIN, ) -from .utils import get_device_macs +from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) @@ -80,6 +81,13 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.context.get(CONF_URL, ""), ), ): str, + vol.Optional( + CONF_VERIFY_SSL, + default=user_input.get( + CONF_VERIFY_SSL, + False, + ), + ): bool, vol.Optional( CONF_USERNAME, default=user_input.get(CONF_USERNAME) or "" ): str, @@ -119,11 +127,20 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): password = user_input.get(CONF_PASSWORD) or "" def _get_connection() -> Connection: + if ( + user_input[CONF_URL].startswith("https://") + and not user_input[CONF_VERIFY_SSL] + ): + requests_session = non_verifying_requests_session(user_input[CONF_URL]) + else: + requests_session = None + return Connection( url=user_input[CONF_URL], username=username, password=password, timeout=CONNECTION_TIMEOUT, + requests_session=requests_session, ) conn = None @@ -140,6 +157,12 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except ResponseErrorException: _LOGGER.warning("Response error", exc_info=True) errors["base"] = "response_error" + except SSLError: + _LOGGER.warning("SSL error", exc_info=True) + if user_input[CONF_VERIFY_SSL]: + errors[CONF_URL] = "ssl_error_try_unverified" + else: + errors[CONF_URL] = "ssl_error_try_plain" except Timeout: _LOGGER.warning("Connection timeout", exc_info=True) errors[CONF_URL] = "connection_timeout" @@ -152,6 +175,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def _disconnect(conn: Connection) -> None: try: conn.close() + conn.requests_session.close() except Exception: # pylint: disable=broad-except _LOGGER.debug("Disconnect error", exc_info=True) diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 1e43aa818e9..9e46ca742b8 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -14,6 +14,8 @@ "invalid_url": "Invalid URL", "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", "response_error": "Unknown error from device", + "ssl_error_try_plain": "HTTPS error, please try a plain HTTP URL", + "ssl_error_try_unverified": "HTTPS error, please try disabling certificate verification or a plain HTTP URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name}", @@ -30,7 +32,8 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "url": "[%key:common::config_flow::data::url%]", - "username": "[%key:common::config_flow::data::username%]" + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "description": "Enter device access details.", "title": "Configure Huawei LTE" diff --git a/homeassistant/components/huawei_lte/utils.py b/homeassistant/components/huawei_lte/utils.py index 172e8658928..df212a1c25d 100644 --- a/homeassistant/components/huawei_lte/utils.py +++ b/homeassistant/components/huawei_lte/utils.py @@ -2,8 +2,13 @@ from __future__ import annotations from contextlib import suppress +import re +from urllib.parse import urlparse +import warnings from huawei_lte_api.Session import GetResponseType +import requests +from urllib3.exceptions import InsecureRequestWarning from homeassistant.helpers.device_registry import format_mac @@ -25,3 +30,18 @@ def get_device_macs( macs.extend(x.get("WifiMac") for x in wlan_settings["Ssids"]["Ssid"]) return sorted({format_mac(str(x)) for x in macs if x}) + + +def non_verifying_requests_session(url: str) -> requests.Session: + """Get requests.Session that does not verify HTTPS, filter warnings about it.""" + parsed_url = urlparse(url) + assert parsed_url.hostname + requests_session = requests.Session() + requests_session.verify = False + warnings.filterwarnings( + "ignore", + message=rf"^.*\b{re.escape(parsed_url.hostname)}\b", + category=InsecureRequestWarning, + module=r"^urllib3\.connectionpool$", + ) + return requests_session diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 13307e43648..e358920b07b 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -1,5 +1,7 @@ """Tests for the Huawei LTE config flow.""" +from typing import Any from unittest.mock import patch +from urllib.parse import urlparse, urlunparse from huawei_lte_api.enums.client import ResponseCodeEnum from huawei_lte_api.enums.user import LoginErrorEnum, LoginStateEnum, PasswordTypeEnum @@ -18,6 +20,7 @@ from homeassistant.const import ( CONF_RECIPIENT, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -25,8 +28,9 @@ from tests.common import MockConfigEntry FIXTURE_UNIQUE_ID = "SERIALNUMBER" -FIXTURE_USER_INPUT = { +FIXTURE_USER_INPUT: dict[str, Any] = { CONF_URL: "http://192.168.1.1/", + CONF_VERIFY_SSL: False, CONF_USERNAME: "admin", CONF_PASSWORD: "secret", } @@ -95,34 +99,59 @@ async def test_already_configured( assert result["reason"] == "already_configured" -async def test_connection_error( - hass: HomeAssistant, requests_mock: requests_mock.Mocker -) -> None: - """Test we show user form on connection error.""" - requests_mock.request(ANY, ANY, exc=ConnectionError()) +@pytest.mark.parametrize( + ("exception", "errors", "data_patch"), + ( + (ConnectionError(), {CONF_URL: "unknown"}, {}), + (requests.exceptions.SSLError(), {CONF_URL: "ssl_error_try_plain"}, {}), + ( + requests.exceptions.SSLError(), + {CONF_URL: "ssl_error_try_unverified"}, + {CONF_VERIFY_SSL: True}, + ), + ), +) +async def test_connection_errors( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + exception: Exception, + errors: dict[str, str], + data_patch: dict[str, Any], +): + """Test we show user form on various errors.""" + requests_mock.request(ANY, ANY, exc=exception) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT | data_patch, ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {CONF_URL: "unknown"} + assert result["errors"] == errors @pytest.fixture def login_requests_mock(requests_mock): """Set up a requests_mock with base mocks for login tests.""" - requests_mock.request( - ANY, FIXTURE_USER_INPUT[CONF_URL], text='' - ) - requests_mock.request( - ANY, - f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/state-login", - text=( - f"{LoginStateEnum.LOGGED_OUT}" - f"{PasswordTypeEnum.SHA256}" - ), + https_url = urlunparse( + urlparse(FIXTURE_USER_INPUT[CONF_URL])._replace(scheme="https") ) + for url in FIXTURE_USER_INPUT[CONF_URL], https_url: + requests_mock.request(ANY, url, text='') + requests_mock.request( + ANY, + f"{url}api/user/state-login", + text=( + f"{LoginStateEnum.LOGGED_OUT}" + f"{PasswordTypeEnum.SHA256}" + ), + ) + requests_mock.request( + ANY, + f"{url}api/user/logout", + text="OK", + ) return requests_mock @@ -194,11 +223,19 @@ async def test_login_error( assert result["errors"] == errors -async def test_success(hass: HomeAssistant, login_requests_mock) -> None: +@pytest.mark.parametrize("scheme", ("http", "https")) +async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> None: """Test successful flow provides entry creation data.""" + user_input = { + **FIXTURE_USER_INPUT, + CONF_URL: urlunparse( + urlparse(FIXTURE_USER_INPUT[CONF_URL])._replace(scheme=scheme) + ), + } + login_requests_mock.request( ANY, - f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + f"{user_input[CONF_URL]}api/user/login", text="OK", ) with patch("homeassistant.components.huawei_lte.async_setup"), patch( @@ -207,14 +244,14 @@ async def test_success(hass: HomeAssistant, login_requests_mock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, + data=user_input, ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_URL] == user_input[CONF_URL] + assert result["data"][CONF_USERNAME] == user_input[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == user_input[CONF_PASSWORD] @pytest.mark.parametrize( @@ -300,8 +337,9 @@ async def test_ssdp( ) for k, v in expected_result.items(): - assert result[k] == v + assert result[k] == v # type: ignore[literal-required] # expected is a subset if result.get("data_schema"): + assert result["data_schema"] is not None assert result["data_schema"]({})[CONF_URL] == url + "/" @@ -355,6 +393,7 @@ async def test_reauth( assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["data_schema"] is not None assert result["data_schema"]({}) == { CONF_USERNAME: mock_entry_data[CONF_USERNAME], CONF_PASSWORD: mock_entry_data[CONF_PASSWORD], @@ -376,7 +415,7 @@ async def test_reauth( await hass.async_block_till_done() for k, v in expected_result.items(): - assert result[k] == v + assert result[k] == v # type: ignore[literal-required] # expected is a subset for k, v in expected_entry_data.items(): assert entry.data[k] == v From 360ef894a75c3dfa91867e5e74959a0c207aff04 Mon Sep 17 00:00:00 2001 From: Thijs Putman Date: Mon, 27 Nov 2023 18:35:46 +0100 Subject: [PATCH 795/982] Use non-persistent connection for MPD (#94507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Do not use a persistent connection to MPD In other words, don't rely on the connection management provided by "python-mpd2". Instead of keeping the connection to MPD open, we explicitly connect before and disconnect after each command. There is probably a bit of overhead to this, but as the integration uses a local-polling approach to begin with, no functionality is lost or degraded. This change greatly hardens the MPD integration against both network issues and problems with the daemon itself. All connection-related failure modes have effectively been removed. * Update state retrieval methods Only "async_get_media_image" attempts to connect, all others are either called from there, or from the main "async_update" method (see previous commit) which also attempts to connect. So, this commit mainly revolves around gracefully handling situations where no connection is available when trying to retrieve MPD state. Finally, note the removal of "self._commands". This property is only used at the start of "_async_get_file_image_response" and was thus changed into a local variable. * Update media-player control methods These all need to explicitly connect to MPD as part of their flow. * Correct ruff failure (auto-fixed) * Use "async_timeout.timeout" context manager * Minor changes * Replace "async_timeout" with "asyncio.timeout" * Initialise "self._status" to empty dictionary Used to be initialised as None, which caused "NoneType is not iterable" type of issues in case of an unexpected disconnect (at which point status gets set to None again). Instead of guarding against None everywhere, using an empty dictionary seemed more prudent... Furthermore, more cautiously access its members to prevent potential KeyError-s in similar cases. * Fix livelock in "async_mute_volume()" This method doesn't need a connection; it calls into two other methods that actually connect to MPD – attempting to connect from here resulted in a livelock. --- homeassistant/components/mpd/media_player.py | 328 +++++++++++-------- 1 file changed, 191 insertions(+), 137 deletions(-) diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 8eab83b5d41..9b3adb38e0c 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -1,11 +1,13 @@ """Support to interact with a Music Player Daemon.""" from __future__ import annotations -from contextlib import suppress +import asyncio +from contextlib import asynccontextmanager, suppress from datetime import timedelta import hashlib import logging import os +from socket import gaierror from typing import Any import mpd @@ -92,11 +94,11 @@ class MpdDevice(MediaPlayerEntity): self._name = name self.password = password - self._status = None + self._status = {} self._currentsong = None self._playlists = None self._currentplaylist = None - self._is_connected = False + self._is_available = None self._muted = False self._muted_volume = None self._media_position_updated_at = None @@ -104,67 +106,88 @@ class MpdDevice(MediaPlayerEntity): self._media_image_hash = None # Track if the song changed so image doesn't have to be loaded every update. self._media_image_file = None - self._commands = None # set up MPD client self._client = MPDClient() self._client.timeout = 30 - self._client.idletimeout = None + self._client.idletimeout = 10 + self._client_lock = asyncio.Lock() - async def _connect(self): - """Connect to MPD.""" - try: - await self._client.connect(self.server, self.port) - - if self.password is not None: - await self._client.password(self.password) - except mpd.ConnectionError: - return - - self._is_connected = True - - def _disconnect(self): - """Disconnect from MPD.""" - with suppress(mpd.ConnectionError): - self._client.disconnect() - self._is_connected = False - self._status = None - - async def _fetch_status(self): - """Fetch status from MPD.""" - self._status = await self._client.status() - self._currentsong = await self._client.currentsong() - await self._async_update_media_image_hash() - - if (position := self._status.get("elapsed")) is None: - position = self._status.get("time") - - if isinstance(position, str) and ":" in position: - position = position.split(":")[0] - - if position is not None and self._media_position != position: - self._media_position_updated_at = dt_util.utcnow() - self._media_position = int(float(position)) - - await self._update_playlists() - - @property - def available(self): - """Return true if MPD is available and connected.""" - return self._is_connected + # Instead of relying on python-mpd2 to maintain a (persistent) connection to + # MPD, the below explicitly sets up a *non*-persistent connection. This is + # done to workaround the issue as described in: + # + @asynccontextmanager + async def connection(self): + """Handle MPD connect and disconnect.""" + async with self._client_lock: + try: + # MPDClient.connect() doesn't always respect its timeout. To + # prevent a deadlock, enforce an additional (slightly longer) + # timeout on the coroutine itself. + try: + async with asyncio.timeout(self._client.timeout + 5): + await self._client.connect(self.server, self.port) + except asyncio.TimeoutError as error: + # TimeoutError has no message (which hinders logging further + # down the line), so provide one. + raise asyncio.TimeoutError( + "Connection attempt timed out" + ) from error + if self.password is not None: + await self._client.password(self.password) + self._is_available = True + yield + except ( + asyncio.TimeoutError, + gaierror, + mpd.ConnectionError, + OSError, + ) as error: + # Log a warning during startup or when previously connected; for + # subsequent errors a debug message is sufficient. + log_level = logging.DEBUG + if self._is_available is not False: + log_level = logging.WARNING + _LOGGER.log( + log_level, "Error connecting to '%s': %s", self.server, error + ) + self._is_available = False + self._status = {} + # Also yield on failure. Handling mpd.ConnectionErrors caused by + # attempting to control a disconnected client is the + # responsibility of the caller. + yield + finally: + with suppress(mpd.ConnectionError): + self._client.disconnect() async def async_update(self) -> None: - """Get the latest data and update the state.""" - try: - if not self._is_connected: - await self._connect() - self._commands = list(await self._client.commands()) + """Get the latest data from MPD and update the state.""" + async with self.connection(): + try: + self._status = await self._client.status() + self._currentsong = await self._client.currentsong() + await self._async_update_media_image_hash() - await self._fetch_status() - except (mpd.ConnectionError, OSError, ValueError) as error: - # Cleanly disconnect in case connection is not in valid state - _LOGGER.debug("Error updating status: %s", error) - self._disconnect() + if (position := self._status.get("elapsed")) is None: + position = self._status.get("time") + + if isinstance(position, str) and ":" in position: + position = position.split(":")[0] + + if position is not None and self._media_position != position: + self._media_position_updated_at = dt_util.utcnow() + self._media_position = int(float(position)) + + await self._update_playlists() + except (mpd.ConnectionError, ValueError) as error: + _LOGGER.debug("Error updating status: %s", error) + + @property + def available(self) -> bool: + """Return true if MPD is available and connected.""" + return self._is_available is True @property def name(self): @@ -174,13 +197,13 @@ class MpdDevice(MediaPlayerEntity): @property def state(self) -> MediaPlayerState: """Return the media state.""" - if self._status is None: + if not self._status: return MediaPlayerState.OFF - if self._status["state"] == "play": + if self._status.get("state") == "play": return MediaPlayerState.PLAYING - if self._status["state"] == "pause": + if self._status.get("state") == "pause": return MediaPlayerState.PAUSED - if self._status["state"] == "stop": + if self._status.get("state") == "stop": return MediaPlayerState.OFF return MediaPlayerState.OFF @@ -259,20 +282,26 @@ class MpdDevice(MediaPlayerEntity): async def async_get_media_image(self) -> tuple[bytes | None, str | None]: """Fetch media image of current playing track.""" - if not (file := self._currentsong.get("file")): - return None, None - response = await self._async_get_file_image_response(file) - if response is None: - return None, None + async with self.connection(): + if self._currentsong is None or not (file := self._currentsong.get("file")): + return None, None - image = bytes(response["binary"]) - mime = response.get( - "type", "image/png" - ) # readpicture has type, albumart does not - return (image, mime) + with suppress(mpd.ConnectionError): + response = await self._async_get_file_image_response(file) + if response is None: + return None, None + + image = bytes(response["binary"]) + mime = response.get( + "type", "image/png" + ) # readpicture has type, albumart does not + return (image, mime) async def _async_update_media_image_hash(self): """Update the hash value for the media image.""" + if self._currentsong is None: + return + file = self._currentsong.get("file") if file == self._media_image_file: @@ -295,16 +324,21 @@ class MpdDevice(MediaPlayerEntity): self._media_image_file = file async def _async_get_file_image_response(self, file): - # not all MPD implementations and versions support the `albumart` and `fetchpicture` commands - can_albumart = "albumart" in self._commands - can_readpicture = "readpicture" in self._commands + # not all MPD implementations and versions support the `albumart` and + # `fetchpicture` commands. + commands = [] + with suppress(mpd.ConnectionError): + commands = list(await self._client.commands()) + can_albumart = "albumart" in commands + can_readpicture = "readpicture" in commands response = None # read artwork embedded into the media file if can_readpicture: try: - response = await self._client.readpicture(file) + with suppress(mpd.ConnectionError): + response = await self._client.readpicture(file) except mpd.CommandError as error: if error.errno is not mpd.FailureResponseCode.NO_EXIST: _LOGGER.warning( @@ -315,7 +349,8 @@ class MpdDevice(MediaPlayerEntity): # read artwork contained in the media directory (cover.{jpg,png,tiff,bmp}) if none is embedded if can_albumart and not response: try: - response = await self._client.albumart(file) + with suppress(mpd.ConnectionError): + response = await self._client.albumart(file) except mpd.CommandError as error: if error.errno is not mpd.FailureResponseCode.NO_EXIST: _LOGGER.warning( @@ -339,7 +374,7 @@ class MpdDevice(MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" - if self._status is None: + if not self._status: return MediaPlayerEntityFeature(0) supported = SUPPORT_MPD @@ -373,55 +408,64 @@ class MpdDevice(MediaPlayerEntity): """Update available MPD playlists.""" try: self._playlists = [] - for playlist_data in await self._client.listplaylists(): - self._playlists.append(playlist_data["playlist"]) + with suppress(mpd.ConnectionError): + for playlist_data in await self._client.listplaylists(): + self._playlists.append(playlist_data["playlist"]) except mpd.CommandError as error: self._playlists = None _LOGGER.warning("Playlists could not be updated: %s:", error) async def async_set_volume_level(self, volume: float) -> None: """Set volume of media player.""" - if "volume" in self._status: - await self._client.setvol(int(volume * 100)) + async with self.connection(): + if "volume" in self._status: + await self._client.setvol(int(volume * 100)) async def async_volume_up(self) -> None: """Service to send the MPD the command for volume up.""" - if "volume" in self._status: - current_volume = int(self._status["volume"]) + async with self.connection(): + if "volume" in self._status: + current_volume = int(self._status["volume"]) - if current_volume <= 100: - self._client.setvol(current_volume + 5) + if current_volume <= 100: + self._client.setvol(current_volume + 5) async def async_volume_down(self) -> None: """Service to send the MPD the command for volume down.""" - if "volume" in self._status: - current_volume = int(self._status["volume"]) + async with self.connection(): + if "volume" in self._status: + current_volume = int(self._status["volume"]) - if current_volume >= 0: - await self._client.setvol(current_volume - 5) + if current_volume >= 0: + await self._client.setvol(current_volume - 5) async def async_media_play(self) -> None: """Service to send the MPD the command for play/pause.""" - if self._status["state"] == "pause": - await self._client.pause(0) - else: - await self._client.play() + async with self.connection(): + if self._status.get("state") == "pause": + await self._client.pause(0) + else: + await self._client.play() async def async_media_pause(self) -> None: """Service to send the MPD the command for play/pause.""" - await self._client.pause(1) + async with self.connection(): + await self._client.pause(1) async def async_media_stop(self) -> None: """Service to send the MPD the command for stop.""" - await self._client.stop() + async with self.connection(): + await self._client.stop() async def async_media_next_track(self) -> None: """Service to send the MPD the command for next track.""" - await self._client.next() + async with self.connection(): + await self._client.next() async def async_media_previous_track(self) -> None: """Service to send the MPD the command for previous track.""" - await self._client.previous() + async with self.connection(): + await self._client.previous() async def async_mute_volume(self, mute: bool) -> None: """Mute. Emulated with set_volume_level.""" @@ -437,75 +481,82 @@ class MpdDevice(MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the media player the command for playing a playlist.""" - if media_source.is_media_source_id(media_id): - media_type = MediaType.MUSIC - play_item = await media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ) - media_id = async_process_play_media_url(self.hass, play_item.url) + async with self.connection(): + if media_source.is_media_source_id(media_id): + media_type = MediaType.MUSIC + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + media_id = async_process_play_media_url(self.hass, play_item.url) - if media_type == MediaType.PLAYLIST: - _LOGGER.debug("Playing playlist: %s", media_id) - if media_id in self._playlists: - self._currentplaylist = media_id + if media_type == MediaType.PLAYLIST: + _LOGGER.debug("Playing playlist: %s", media_id) + if media_id in self._playlists: + self._currentplaylist = media_id + else: + self._currentplaylist = None + _LOGGER.warning("Unknown playlist name %s", media_id) + await self._client.clear() + await self._client.load(media_id) + await self._client.play() else: + await self._client.clear() self._currentplaylist = None - _LOGGER.warning("Unknown playlist name %s", media_id) - await self._client.clear() - await self._client.load(media_id) - await self._client.play() - else: - await self._client.clear() - self._currentplaylist = None - await self._client.add(media_id) - await self._client.play() + await self._client.add(media_id) + await self._client.play() @property def repeat(self) -> RepeatMode: """Return current repeat mode.""" - if self._status["repeat"] == "1": - if self._status["single"] == "1": + if self._status.get("repeat") == "1": + if self._status.get("single") == "1": return RepeatMode.ONE return RepeatMode.ALL return RepeatMode.OFF async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" - if repeat == RepeatMode.OFF: - await self._client.repeat(0) - await self._client.single(0) - else: - await self._client.repeat(1) - if repeat == RepeatMode.ONE: - await self._client.single(1) - else: + async with self.connection(): + if repeat == RepeatMode.OFF: + await self._client.repeat(0) await self._client.single(0) + else: + await self._client.repeat(1) + if repeat == RepeatMode.ONE: + await self._client.single(1) + else: + await self._client.single(0) @property def shuffle(self): """Boolean if shuffle is enabled.""" - return bool(int(self._status["random"])) + return bool(int(self._status.get("random"))) async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" - await self._client.random(int(shuffle)) + async with self.connection(): + await self._client.random(int(shuffle)) async def async_turn_off(self) -> None: """Service to send the MPD the command to stop playing.""" - await self._client.stop() + async with self.connection(): + await self._client.stop() async def async_turn_on(self) -> None: """Service to send the MPD the command to start playing.""" - await self._client.play() - await self._update_playlists(no_throttle=True) + async with self.connection(): + await self._client.play() + await self._update_playlists(no_throttle=True) async def async_clear_playlist(self) -> None: """Clear players playlist.""" - await self._client.clear() + async with self.connection(): + await self._client.clear() async def async_media_seek(self, position: float) -> None: """Send seek command.""" - await self._client.seekcur(position) + async with self.connection(): + await self._client.seekcur(position) async def async_browse_media( self, @@ -513,8 +564,11 @@ class MpdDevice(MediaPlayerEntity): media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( - self.hass, - media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), - ) + async with self.connection(): + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith( + "audio/" + ), + ) From ba33ad6b184838e4f79b3ef870f1ca8ee81b412a Mon Sep 17 00:00:00 2001 From: On Freund Date: Mon, 27 Nov 2023 21:21:07 +0200 Subject: [PATCH 796/982] OurGroceries review comments (#104606) --- .../components/ourgroceries/coordinator.py | 14 ++++++++++---- homeassistant/components/ourgroceries/strings.json | 3 --- homeassistant/components/ourgroceries/todo.py | 13 +++++++------ 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index a4b594c7e86..636ebcc300a 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -1,6 +1,7 @@ """The OurGroceries coordinator.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -25,6 +26,7 @@ class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Initialize global OurGroceries data updater.""" self.og = og self.lists = lists + self._ids = [sl["id"] for sl in lists] interval = timedelta(seconds=SCAN_INTERVAL) super().__init__( hass, @@ -35,7 +37,11 @@ class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): async def _async_update_data(self) -> dict[str, dict]: """Fetch data from OurGroceries.""" - return { - sl["id"]: (await self.og.get_list_items(list_id=sl["id"])) - for sl in self.lists - } + return dict( + zip( + self._ids, + await asyncio.gather( + *[self.og.get_list_items(list_id=id) for id in self._ids] + ), + ) + ) diff --git a/homeassistant/components/ourgroceries/strings.json b/homeassistant/components/ourgroceries/strings.json index 96dc8b371d1..78a46954183 100644 --- a/homeassistant/components/ourgroceries/strings.json +++ b/homeassistant/components/ourgroceries/strings.json @@ -12,9 +12,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } } diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 98029b09ba8..8115066d0fb 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -1,6 +1,7 @@ """A todo platform for OurGroceries.""" import asyncio +from typing import Any from homeassistant.components.todo import ( TodoItem, @@ -28,6 +29,12 @@ async def async_setup_entry( ) +def _completion_status(item: dict[str, Any]) -> TodoItemStatus: + if item.get("crossedOffAt", False): + return TodoItemStatus.COMPLETED + return TodoItemStatus.NEEDS_ACTION + + class OurGroceriesTodoListEntity( CoordinatorEntity[OurGroceriesDataUpdateCoordinator], TodoListEntity ): @@ -58,12 +65,6 @@ class OurGroceriesTodoListEntity( if self.coordinator.data is None: self._attr_todo_items = None else: - - def _completion_status(item): - if item.get("crossedOffAt", False): - return TodoItemStatus.COMPLETED - return TodoItemStatus.NEEDS_ACTION - self._attr_todo_items = [ TodoItem( summary=item["name"], From b994141bc64980cfbabf5bcaf6f7faccf757f09c Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 27 Nov 2023 22:23:37 +0200 Subject: [PATCH 797/982] CI: simplify Ruff-related things (#104602) --- .github/workflows/ci.yaml | 27 ++++++------------------- .github/workflows/matchers/ruff.json | 30 ---------------------------- 2 files changed, 6 insertions(+), 51 deletions(-) delete mode 100644 .github/workflows/matchers/ruff.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ba2917042af..69e727d3aa9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -292,17 +292,12 @@ jobs: key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - - name: Run ruff-format (fully) + - name: Run ruff-format run: | . venv/bin/activate pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure - - name: Run ruff-format (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual ruff-format --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure + env: + RUFF_OUTPUT_FORMAT: github lint-ruff: name: Check ruff @@ -337,22 +332,12 @@ jobs: key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - - name: Register ruff problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/ruff.json" - - name: Run ruff (fully) - if: needs.info.outputs.test_full_suite == 'true' + - name: Run ruff run: | . venv/bin/activate pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure - - name: Run ruff (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual ruff --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - + env: + RUFF_OUTPUT_FORMAT: github lint-other: name: Check other linters runs-on: ubuntu-22.04 diff --git a/.github/workflows/matchers/ruff.json b/.github/workflows/matchers/ruff.json deleted file mode 100644 index d189a3656a5..00000000000 --- a/.github/workflows/matchers/ruff.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "ruff-error", - "severity": "error", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - }, - { - "owner": "ruff-warning", - "severity": "warning", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - } - ] -} From 7efc581a4801b59dca59bb0c2040683905ca5b9f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 27 Nov 2023 21:35:13 +0100 Subject: [PATCH 798/982] Remove duplicate fixture from bsblan (#104612) --- tests/components/bsblan/conftest.py | 20 +++++--------------- tests/components/bsblan/test_config_flow.py | 10 +++++----- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index 44d87745b3f..b7939e4cb50 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -38,25 +38,15 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup -@pytest.fixture -def mock_bsblan_config_flow() -> Generator[None, MagicMock, None]: - """Return a mocked BSBLAN client.""" - with patch( - "homeassistant.components.bsblan.config_flow.BSBLAN", autospec=True - ) as bsblan_mock: - bsblan = bsblan_mock.return_value - bsblan.device.return_value = Device.parse_raw( - load_fixture("device.json", DOMAIN) - ) - bsblan.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) - yield bsblan - - @pytest.fixture def mock_bsblan(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: """Return a mocked BSBLAN client.""" - with patch("homeassistant.components.bsblan.BSBLAN", autospec=True) as bsblan_mock: + with patch( + "homeassistant.components.bsblan.BSBLAN", autospec=True + ) as bsblan_mock, patch( + "homeassistant.components.bsblan.config_flow.BSBLAN", new=bsblan_mock + ): bsblan = bsblan_mock.return_value bsblan.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) bsblan.device.return_value = Device.parse_raw( diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index dce881f2f7d..d82c32463d8 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_bsblan_config_flow: MagicMock, + mock_bsblan: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the full manual user flow from start to finish.""" @@ -52,7 +52,7 @@ async def test_full_user_flow_implementation( assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_bsblan_config_flow.device.mock_calls) == 1 + assert len(mock_bsblan.device.mock_calls) == 1 async def test_show_user_form(hass: HomeAssistant) -> None: @@ -68,10 +68,10 @@ async def test_show_user_form(hass: HomeAssistant) -> None: async def test_connection_error( hass: HomeAssistant, - mock_bsblan_config_flow: MagicMock, + mock_bsblan: MagicMock, ) -> None: """Test we show user form on BSBLan connection error.""" - mock_bsblan_config_flow.device.side_effect = BSBLANConnectionError + mock_bsblan.device.side_effect = BSBLANConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -92,7 +92,7 @@ async def test_connection_error( async def test_user_device_exists_abort( hass: HomeAssistant, - mock_bsblan_config_flow: MagicMock, + mock_bsblan: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test we abort flow if BSBLAN device already configured.""" From 04ba7fcbf4b9fbef2092175b2eb59a7f3136355a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 27 Nov 2023 22:42:11 +0200 Subject: [PATCH 799/982] Update leftover comment reference from black to ruff (#104605) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 888743c1d6a..5661b7ca130 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,7 @@ class-const-naming-style = "any" [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: -# format - handled by black +# format - handled by ruff # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load From 5cde36736628a71f5bd7ed8f1898eda7b2a0245d Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Mon, 27 Nov 2023 20:49:33 +0000 Subject: [PATCH 800/982] Bump ring_doorbell to 0.8.3 (#104611) --- 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 a20d9b4c90f..36514fc8f35 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell[listen]==0.8.2"] + "requirements": ["ring-doorbell[listen]==0.8.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 61fed306468..0dc5ca8b2bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2354,7 +2354,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.2 +ring-doorbell[listen]==0.8.3 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c6281c0007..5a7f61d47ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1760,7 +1760,7 @@ reolink-aio==0.8.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.2 +ring-doorbell[listen]==0.8.3 # homeassistant.components.roku rokuecp==0.18.1 From fd5cda4ec60cd1f7f1f4f6746ae9c266d943b05e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 27 Nov 2023 22:59:54 +0200 Subject: [PATCH 801/982] Issue bytes vs str related warnings from tests (#101186) --- .github/workflows/ci.yaml | 8 ++++---- script/lint_and_test.py | 1 + script/scaffold/__main__.py | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 69e727d3aa9..71030e50074 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -747,7 +747,7 @@ jobs: cov_params+=(--cov-report=xml) fi - python3 -X dev -m pytest \ + python3 -b -X dev -m pytest \ -qq \ --timeout=9 \ --durations=10 \ @@ -784,7 +784,7 @@ jobs: cov_params+=(--cov-report=term-missing) fi - python3 -X dev -m pytest \ + python3 -b -X dev -m pytest \ -qq \ --timeout=9 \ -n auto \ @@ -905,7 +905,7 @@ jobs: cov_params+=(--cov-report=term-missing) fi - python3 -X dev -m pytest \ + python3 -b -X dev -m pytest \ -qq \ --timeout=20 \ -n 1 \ @@ -1029,7 +1029,7 @@ jobs: cov_params+=(--cov-report=term-missing) fi - python3 -X dev -m pytest \ + python3 -b -X dev -m pytest \ -qq \ --timeout=9 \ -n 1 \ diff --git a/script/lint_and_test.py b/script/lint_and_test.py index ee28d4765d6..48809ae4dcd 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -224,6 +224,7 @@ async def main(): code, _ = await async_exec( "python3", + "-b", "-m", "pytest", "-vv", diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 8dafd8fa802..ddbd1189e11 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -103,10 +103,11 @@ def main(): if args.develop: print("Running tests") - print(f"$ python3 -m pytest -vvv tests/components/{info.domain}") + print(f"$ python3 -b -m pytest -vvv tests/components/{info.domain}") subprocess.run( [ "python3", + "-b", "-m", "pytest", "-vvv", From e594c19c1ecb9bc947b37d2af75e8d84f6a922e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 27 Nov 2023 23:05:46 +0200 Subject: [PATCH 802/982] Upgrade huawei-lte-api to 1.7.3 (#104613) --- homeassistant/components/huawei_lte/__init__.py | 9 +++++---- homeassistant/components/huawei_lte/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 62efabf1f5e..d8c939e5c3a 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -307,10 +307,11 @@ class Router: """Log out router session.""" try: self.client.user.logout() - except ResponseErrorNotSupportedException: - _LOGGER.debug("Logout not supported by device", exc_info=True) - except ResponseErrorLoginRequiredException: - _LOGGER.debug("Logout not supported when not logged in", exc_info=True) + except ( + ResponseErrorLoginRequiredException, + ResponseErrorNotSupportedException, + ): + pass # Ok, normal, nothing to do except Exception: # pylint: disable=broad-except _LOGGER.warning("Logout error", exc_info=True) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index d563bed4d46..9a44024111c 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": [ - "huawei-lte-api==1.6.11", + "huawei-lte-api==1.7.3", "stringcase==1.2.0", "url-normalize==1.4.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 0dc5ca8b2bc..1c19ac6ff47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1036,7 +1036,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.6.11 +huawei-lte-api==1.7.3 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a7f61d47ef..2feb3b68b63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -820,7 +820,7 @@ homepluscontrol==0.0.5 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.6.11 +huawei-lte-api==1.7.3 # homeassistant.components.hyperion hyperion-py==0.7.5 From 7d45227a5966bec0d265026dd36bd83df7e46cff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Nov 2023 19:48:29 -0600 Subject: [PATCH 803/982] Bump aioesphomeapi to 19.1.3 (#104628) - small fix for aioesphomeapi-discover - 100% line coverage for the library - fix not backing off on encryption errors (fixes #104624) changelog: https://github.com/esphome/aioesphomeapi/compare/v19.1.1...v19.1.3 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 26eaac23895..27ad2f45e81 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==19.1.1", + "aioesphomeapi==19.1.3", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1c19ac6ff47..a81b54ba94f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.1 +aioesphomeapi==19.1.3 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2feb3b68b63..a54c605ba70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.1 +aioesphomeapi==19.1.3 # homeassistant.components.flo aioflo==2021.11.0 From e048ad5a628ccc5dbb3eb1f7548688759bb21020 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Nov 2023 01:20:04 -0600 Subject: [PATCH 804/982] Bump aioesphomeapi to 19.1.4 (#104629) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 27ad2f45e81..763a7e5b833 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==19.1.3", + "aioesphomeapi==19.1.4", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index a81b54ba94f..bb646832361 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.3 +aioesphomeapi==19.1.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a54c605ba70..f0c22d7ed25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.3 +aioesphomeapi==19.1.4 # homeassistant.components.flo aioflo==2021.11.0 From a1aff5f4a0bf415d0e357c4f7c0197351a334726 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Nov 2023 23:27:51 -0800 Subject: [PATCH 805/982] Add websocket `todo/item/subscribe` for subscribing to changes to todo list items (#103952) Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- homeassistant/components/todo/__init__.py | 97 ++++++++++++++++++++- tests/components/caldav/test_todo.py | 71 ++++++++++++++- tests/components/google_tasks/test_todo.py | 76 ++++++++++++++++ tests/components/local_todo/test_todo.py | 61 +++++++++++++ tests/components/shopping_list/test_todo.py | 66 ++++++++++++++ tests/components/todo/test_init.py | 82 +++++++++++++++++ tests/components/todoist/test_todo.py | 60 +++++++++++++ 7 files changed, 510 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 4b76ee5a689..be3c0b57593 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -1,9 +1,10 @@ """The todo integration.""" +from collections.abc import Callable import dataclasses import datetime import logging -from typing import Any +from typing import Any, final import voluptuous as vol @@ -11,7 +12,13 @@ from homeassistant.components import frontend, websocket_api from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + ServiceCall, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -21,6 +28,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.json import JsonValueType from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature @@ -39,6 +47,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: frontend.async_register_built_in_panel(hass, "todo", "todo", "mdi:clipboard-list") + websocket_api.async_register_command(hass, websocket_handle_subscribe_todo_items) websocket_api.async_register_command(hass, websocket_handle_todo_item_list) websocket_api.async_register_command(hass, websocket_handle_todo_item_move) @@ -131,6 +140,7 @@ class TodoListEntity(Entity): """An entity that represents a To-do list.""" _attr_todo_items: list[TodoItem] | None = None + _update_listeners: list[Callable[[list[JsonValueType] | None], None]] | None = None @property def state(self) -> int | None: @@ -168,6 +178,89 @@ class TodoListEntity(Entity): """ raise NotImplementedError() + @final + @callback + def async_subscribe_updates( + self, + listener: Callable[[list[JsonValueType] | None], None], + ) -> CALLBACK_TYPE: + """Subscribe to To-do list item updates. + + Called by websocket API. + """ + if self._update_listeners is None: + self._update_listeners = [] + self._update_listeners.append(listener) + + @callback + def unsubscribe() -> None: + if self._update_listeners: + self._update_listeners.remove(listener) + + return unsubscribe + + @final + @callback + def async_update_listeners(self) -> None: + """Push updated To-do items to all listeners.""" + if not self._update_listeners: + return + + todo_items: list[JsonValueType] = [ + dataclasses.asdict(item) for item in self.todo_items or () + ] + for listener in self._update_listeners: + listener(todo_items) + + @callback + def _async_write_ha_state(self) -> None: + """Notify to-do item subscribers.""" + super()._async_write_ha_state() + self.async_update_listeners() + + +@websocket_api.websocket_command( + { + vol.Required("type"): "todo/item/subscribe", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +@websocket_api.async_response +async def websocket_handle_subscribe_todo_items( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to To-do list item updates.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + entity_id: str = msg["entity_id"] + + if not (entity := component.get_entity(entity_id)): + connection.send_error( + msg["id"], + "invalid_entity_id", + f"To-do list entity not found: {entity_id}", + ) + return + + @callback + def todo_item_listener(todo_items: list[JsonValueType] | None) -> None: + """Push updated To-do list items to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "items": todo_items, + }, + ) + ) + + connection.subscriptions[msg["id"]] = entity.async_subscribe_updates( + todo_item_listener + ) + connection.send_result(msg["id"]) + + # Push an initial forecast update + entity.async_update_listeners() + @websocket_api.websocket_command( { diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 31901515e5a..55ae0d564d0 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator CALENDAR_NAME = "My Tasks" ENTITY_NAME = "My tasks" @@ -190,7 +191,7 @@ async def test_add_item( assert state assert state.state == "0" - # Simulat return value for the state update after the service call + # Simulate return value for the state update after the service call calendar.search.return_value = [create_todo(calendar, "2", TODO_NEEDS_ACTION)] await hass.services.async_call( @@ -496,3 +497,71 @@ async def test_remove_item_not_found( target={"entity_id": TEST_ENTITY}, blocking=True, ) + + +async def test_subscribe( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test creating a an item on the list.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Cheese" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] + + calendar.todo_by_uid = MagicMock(return_value=item) + dav_client.put.return_value.status = 204 + # Reflect update for state refresh after update + calendar.search.return_value = [ + Todo( + dav_client, None, TODO_NEEDS_ACTION.replace("Cheese", "Milk"), calendar, "2" + ) + ] + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "rename": "Milk", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Milk" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 8b0b49ee109..0b82815b33a 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -796,3 +796,79 @@ async def test_parent_child_ordering( items = await ws_get_items() assert items == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + # refresh after update + { + "items": [ + { + "id": "some-task-id", + "title": "Milk", + "status": "needsAction", + "position": "0000000000000001", + }, + ], + }, + ] + ], +) +async def test_susbcribe( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, +) -> None: + """Test subscribing to item updates.""" + + assert await integration_setup() + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.my_tasks", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Water" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": uid, "rename": "Milk"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index c6246be3dad..5e6aff9cbf3 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -445,3 +445,64 @@ async def test_parse_existing_ics( state = hass.states.get(TEST_ENTITY) assert state assert state.state == expected_state + + +async def test_susbcribe( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test subscribing to item updates.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": uid, "rename": "milk"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 7c13344ad1d..7722bd8b6da 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -444,3 +444,69 @@ async def test_move_invalid_item( assert not resp.get("success") assert resp.get("error", {}).get("code") == "failed" assert "could not be re-ordered" in resp.get("error", {}).get("message") + + +async def test_subscribe_item( + hass: HomeAssistant, + sl_setup: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + { + "item": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item item completed + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "soda", + "rename": "milk", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 907ee695ed1..a65cce27349 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -941,3 +941,85 @@ async def test_remove_completed_items_service_raises( target={"entity_id": "todo.entity1"}, blocking=True, ) + + +async def test_subscribe( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, +) -> None: + """Test subscribing to todo updates.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": test_entity.entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + event_message = msg["event"] + assert event_message == { + "items": [ + {"summary": "Item #1", "uid": "1", "status": "needs_action"}, + {"summary": "Item #2", "uid": "2", "status": "completed"}, + ] + } + test_entity._attr_todo_items = [ + *test_entity._attr_todo_items, + TodoItem(summary="Item #3", uid="3", status=TodoItemStatus.NEEDS_ACTION), + ] + + test_entity.async_write_ha_state() + msg = await client.receive_json() + event_message = msg["event"] + assert event_message == { + "items": [ + {"summary": "Item #1", "uid": "1", "status": "needs_action"}, + {"summary": "Item #2", "uid": "2", "status": "completed"}, + {"summary": "Item #3", "uid": "3", "status": "needs_action"}, + ] + } + + test_entity._attr_todo_items = None + test_entity.async_write_ha_state() + msg = await client.receive_json() + event_message = msg["event"] + assert event_message == { + "items": [], + } + + +async def test_subscribe_entity_does_not_exist( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, +) -> None: + """Test failure to subscribe to an entity that does not exist.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.unknown", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "invalid_entity_id", + "message": "To-do list entity not found: todo.unknown", + } diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index a14f362ea5b..fb6f707be47 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -10,6 +10,8 @@ from homeassistant.helpers.entity_component import async_update_entity from .conftest import PROJECT_ID, make_api_task +from tests.typing import WebSocketGenerator + @pytest.fixture(autouse=True) def platforms() -> list[Platform]: @@ -230,3 +232,61 @@ async def test_remove_todo_item( state = hass.states.get("todo.name") assert state assert state.state == "0" + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Cheese", is_completed=False)]] +) +async def test_subscribe( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test for subscribing to state updates.""" + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.name", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Cheese" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] + + # Fake API response when state is refreshed + api.get_tasks.return_value = [ + make_api_task(id="test-id-1", content="Wine", is_completed=False) + ] + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "Cheese", "rename": "Wine"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Wine" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] From d318155f0965ce26557b8df5160cbc762e9b355c Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Nov 2023 02:34:41 -0500 Subject: [PATCH 806/982] Move to new service handeling methods in Blink (#103435) --- .coveragerc | 1 + homeassistant/components/blink/__init__.py | 97 +---- homeassistant/components/blink/coordinator.py | 3 +- homeassistant/components/blink/services.py | 150 +++++++ tests/components/blink/conftest.py | 2 +- tests/components/blink/test_init.py | 171 +------- tests/components/blink/test_services.py | 393 ++++++++++++++++++ 7 files changed, 561 insertions(+), 256 deletions(-) create mode 100644 homeassistant/components/blink/services.py create mode 100644 tests/components/blink/test_services.py diff --git a/.coveragerc b/.coveragerc index 1075c0c3e35..884afdcf408 100644 --- a/.coveragerc +++ b/.coveragerc @@ -115,6 +115,7 @@ omit = homeassistant/components/beewi_smartclim/sensor.py homeassistant/components/bitcoin/sensor.py homeassistant/components/bizkaibus/sensor.py + homeassistant/components/blink/__init__.py homeassistant/components/blink/alarm_control_panel.py homeassistant/components/blink/binary_sensor.py homeassistant/components/blink/camera.py diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 7c586a94c3c..42ad5cabeb7 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -21,17 +21,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - PLATFORMS, - SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_VIDEO, - SERVICE_SEND_PIN, -) +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import BlinkUpdateCoordinator +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -43,6 +37,8 @@ SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string} ) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def _reauth_flow_wrapper(hass, data): """Reauth flow wrapper.""" @@ -75,6 +71,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Blink.""" + + await async_setup_services(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Blink via config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -105,40 +109,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) - async def blink_refresh(event_time=None): - """Call blink to refresh info.""" - await coordinator.api.refresh(force_cache=True) - - async def async_save_video(call): - """Call save video service handler.""" - await async_handle_save_video_service(hass, entry, call) - - async def async_save_recent_clips(call): - """Call save recent clips service handler.""" - await async_handle_save_recent_clips_service(hass, entry, call) - - async def send_pin(call): - """Call blink to send new pin.""" - pin = call.data[CONF_PIN] - await coordinator.api.auth.send_auth_key( - hass.data[DOMAIN][entry.entry_id].api, - pin, - ) - - hass.services.async_register(DOMAIN, SERVICE_REFRESH, blink_refresh) - hass.services.async_register( - DOMAIN, SERVICE_SAVE_VIDEO, async_save_video, schema=SERVICE_SAVE_VIDEO_SCHEMA - ) - hass.services.async_register( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - async_save_recent_clips, - schema=SERVICE_SAVE_RECENT_CLIPS_SCHEMA, - ) - hass.services.async_register( - DOMAIN, SERVICE_SEND_PIN, send_pin, schema=SERVICE_SEND_PIN_SCHEMA - ) - return True @@ -158,13 +128,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Blink entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - if len(hass.data[DOMAIN]) > 0: - return unload_ok - - hass.services.async_remove(DOMAIN, SERVICE_REFRESH) - hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) - hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN) - return unload_ok @@ -172,37 +135,3 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" blink: Blink = hass.data[DOMAIN][entry.entry_id].api blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL] - - -async def async_handle_save_video_service( - hass: HomeAssistant, entry: ConfigEntry, call -) -> None: - """Handle save video service calls.""" - camera_name = call.data[CONF_NAME] - video_path = call.data[CONF_FILENAME] - if not hass.config.is_allowed_path(video_path): - _LOGGER.error("Can't write %s, no access to path!", video_path) - return - all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].video_to_file(video_path) - except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) - - -async def async_handle_save_recent_clips_service( - hass: HomeAssistant, entry: ConfigEntry, call -) -> None: - """Save multiple recent clips to output directory.""" - camera_name = call.data[CONF_NAME] - clips_dir = call.data[CONF_FILE_PATH] - if not hass.config.is_allowed_path(clips_dir): - _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) - return - all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].save_recent_clips(output_dir=clips_dir) - except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index d3f7551e1b2..d53d23c4344 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -13,6 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = 30 class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -25,7 +26,7 @@ class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=timedelta(seconds=SCAN_INTERVAL), ) async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py new file mode 100644 index 00000000000..8ea0b6c03a4 --- /dev/null +++ b/homeassistant/components/blink/services.py @@ -0,0 +1,150 @@ +"""Services for the Blink integration.""" +from __future__ import annotations + +import logging + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_FILE_PATH, + CONF_FILENAME, + CONF_NAME, + CONF_PIN, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr + +from .const import ( + DOMAIN, + SERVICE_REFRESH, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_SEND_PIN, +) +from .coordinator import BlinkUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_FILENAME): cv.string, + } +) +SERVICE_SEND_PIN_SCHEMA = vol.Schema( + {vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Optional(CONF_PIN): cv.string} +) +SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_FILE_PATH): cv.string, + } +) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Blink integration.""" + + async def collect_coordinators( + device_ids: list[str], + ) -> list[BlinkUpdateCoordinator]: + config_entries = list[ConfigEntry]() + registry = dr.async_get(hass) + for target in device_ids: + device = registry.async_get(target) + if device: + device_entries = list[ConfigEntry]() + for entry_id in device.config_entries: + entry = hass.config_entries.async_get_entry(entry_id) + if entry and entry.domain == DOMAIN: + device_entries.append(entry) + if not device_entries: + raise HomeAssistantError( + f"Device '{target}' is not a {DOMAIN} device" + ) + config_entries.extend(device_entries) + else: + raise HomeAssistantError( + f"Device '{target}' not found in device registry" + ) + coordinators = list[BlinkUpdateCoordinator]() + for config_entry in config_entries: + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError(f"{config_entry.title} is not loaded") + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) + return coordinators + + async def async_handle_save_video_service(call: ServiceCall) -> None: + """Handle save video service calls.""" + camera_name = call.data[CONF_NAME] + video_path = call.data[CONF_FILENAME] + if not hass.config.is_allowed_path(video_path): + _LOGGER.error("Can't write %s, no access to path!", video_path) + return + for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + all_cameras = coordinator.api.cameras + if camera_name in all_cameras: + try: + await all_cameras[camera_name].video_to_file(video_path) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) + + async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: + """Save multiple recent clips to output directory.""" + camera_name = call.data[CONF_NAME] + clips_dir = call.data[CONF_FILE_PATH] + if not hass.config.is_allowed_path(clips_dir): + _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) + return + for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + all_cameras = coordinator.api.cameras + if camera_name in all_cameras: + try: + await all_cameras[camera_name].save_recent_clips( + output_dir=clips_dir + ) + except OSError as err: + _LOGGER.error("Can't write recent clips to directory: %s", err) + + async def send_pin(call: ServiceCall): + """Call blink to send new pin.""" + for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + await coordinator.api.auth.send_auth_key( + coordinator.api, + call.data[CONF_PIN], + ) + + async def blink_refresh(call: ServiceCall): + """Call blink to refresh info.""" + for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + await coordinator.api.refresh(force_cache=True) + + # Register all the above services + service_mapping = [ + (blink_refresh, SERVICE_REFRESH, None), + ( + async_handle_save_video_service, + SERVICE_SAVE_VIDEO, + SERVICE_SAVE_VIDEO_SCHEMA, + ), + ( + async_handle_save_recent_clips_service, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_RECENT_CLIPS_SCHEMA, + ), + (send_pin, SERVICE_SEND_PIN, SERVICE_SEND_PIN_SCHEMA), + ] + + for service_handler, service_name, schema in service_mapping: + hass.services.async_register( + DOMAIN, + service_name, + service_handler, + schema=schema, + ) diff --git a/tests/components/blink/conftest.py b/tests/components/blink/conftest.py index 4a731b0a8ee..946840c23b9 100644 --- a/tests/components/blink/conftest.py +++ b/tests/components/blink/conftest.py @@ -13,7 +13,7 @@ from tests.common import MockConfigEntry CAMERA_ATTRIBUTES = { "name": "Camera 1", "camera_id": "111111", - "serial": "serail", + "serial": "serial", "temperature": None, "temperature_c": 25.1, "temperature_calibrated": None, diff --git a/tests/components/blink/test_init.py b/tests/components/blink/test_init.py index 76f4a6370e8..f3d9beaf21a 100644 --- a/tests/components/blink/test_init.py +++ b/tests/components/blink/test_init.py @@ -1,6 +1,6 @@ """Test the Blink init.""" import asyncio -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock from aiohttp import ClientError import pytest @@ -8,12 +8,10 @@ import pytest from homeassistant.components.blink.const import ( DOMAIN, SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME, CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -61,25 +59,6 @@ async def test_setup_not_ready_authkey_required( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR -async def test_unload_entry( - hass: HomeAssistant, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test being able to unload an entry.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - assert not hass.services.has_service(DOMAIN, SERVICE_REFRESH) - assert not hass.services.has_service(DOMAIN, SERVICE_SAVE_VIDEO) - assert not hass.services.has_service(DOMAIN, SERVICE_SEND_PIN) - - async def test_unload_entry_multiple( hass: HomeAssistant, mock_blink_api: MagicMock, @@ -135,151 +114,3 @@ async def test_migrate( await hass.async_block_till_done() entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) assert entry.state is ConfigEntryState.MIGRATION_ERROR - - -async def test_refresh_service_calls( - hass: HomeAssistant, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test refrest service calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - await hass.services.async_call( - DOMAIN, - SERVICE_REFRESH, - blocking=True, - ) - - assert mock_blink_api.refresh.call_count == 2 - - -async def test_video_service_calls( - hass: HomeAssistant, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test video service calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - {CONF_NAME: CAMERA_NAME, CONF_FILENAME: FILENAME}, - blocking=True, - ) - assert "no access to path!" in caplog.text - - hass.config.is_allowed_path = Mock(return_value=True) - caplog.clear() - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - {CONF_NAME: CAMERA_NAME, CONF_FILENAME: FILENAME}, - blocking=True, - ) - mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - caplog.clear() - - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - {CONF_NAME: CAMERA_NAME, CONF_FILENAME: FILENAME}, - blocking=True, - ) - assert "Can't write image" in caplog.text - - hass.config.is_allowed_path = Mock(return_value=False) - - -async def test_picture_service_calls( - hass: HomeAssistant, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test picture servcie calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - {CONF_NAME: CAMERA_NAME, CONF_FILE_PATH: FILENAME}, - blocking=True, - ) - assert "no access to path!" in caplog.text - - hass.config.is_allowed_path = Mock(return_value=True) - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - {CONF_NAME: CAMERA_NAME, CONF_FILE_PATH: FILENAME}, - blocking=True, - ) - mock_blink_api.cameras[CAMERA_NAME].save_recent_clips.assert_awaited_once() - - mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( - side_effect=OSError - ) - caplog.clear() - - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - {CONF_NAME: CAMERA_NAME, CONF_FILE_PATH: FILENAME}, - blocking=True, - ) - assert "Can't write recent clips to directory" in caplog.text - - -async def test_pin_service_calls( - hass: HomeAssistant, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test pin service calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - await hass.services.async_call( - DOMAIN, - SERVICE_SEND_PIN, - {CONF_PIN: PIN}, - blocking=True, - ) - assert mock_blink_api.auth.send_auth_key.assert_awaited_once diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py new file mode 100644 index 00000000000..438b47f38c5 --- /dev/null +++ b/tests/components/blink/test_services.py @@ -0,0 +1,393 @@ +"""Test the Blink services.""" +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from homeassistant.components.blink.const import ( + DOMAIN, + SERVICE_REFRESH, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_SEND_PIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_FILE_PATH, + CONF_FILENAME, + CONF_NAME, + CONF_PIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +CAMERA_NAME = "Camera 1" +FILENAME = "blah" +PIN = "1234" + + +async def test_refresh_service_calls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test refrest service calls.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + + assert device_entry + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH, + {ATTR_DEVICE_ID: [device_entry.id]}, + blocking=True, + ) + + assert mock_blink_api.refresh.call_count == 2 + + with pytest.raises(HomeAssistantError) as execinfo: + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH, + {ATTR_DEVICE_ID: ["bad-device_id"]}, + blocking=True, + ) + + assert "Device 'bad-device_id' not found in device registry" in str(execinfo) + + +async def test_video_service_calls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test video service calls.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + + assert device_entry + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + caplog.clear() + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) + assert "no access to path!" in caplog.text + + hass.config.is_allowed_path = Mock(return_value=True) + caplog.clear() + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) + mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() + + with pytest.raises(HomeAssistantError) as execinfo: + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: ["bad-device_id"], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) + + assert "Device 'bad-device_id' not found in device registry" in str(execinfo) + + mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) + caplog.clear() + + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) + assert "Can't write image" in caplog.text + + hass.config.is_allowed_path = Mock(return_value=False) + + +async def test_picture_service_calls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test picture servcie calls.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + + assert device_entry + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + caplog.clear() + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) + assert "no access to path!" in caplog.text + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) + mock_blink_api.cameras[CAMERA_NAME].save_recent_clips.assert_awaited_once() + + mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( + side_effect=OSError + ) + caplog.clear() + + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) + assert "Can't write recent clips to directory" in caplog.text + + with pytest.raises(HomeAssistantError) as execinfo: + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: ["bad-device_id"], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) + + assert "Device 'bad-device_id' not found in device registry" in str(execinfo) + + +async def test_pin_service_calls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pin service calls.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + + assert device_entry + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + {ATTR_DEVICE_ID: [device_entry.id], CONF_PIN: PIN}, + blocking=True, + ) + assert mock_blink_api.auth.send_auth_key.assert_awaited_once + + with pytest.raises(HomeAssistantError) as execinfo: + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + {ATTR_DEVICE_ID: ["bad-device_id"], CONF_PIN: PIN}, + blocking=True, + ) + + assert "Device 'bad-device_id' not found in device registry" in str(execinfo) + + +@pytest.mark.parametrize( + ("service", "params"), + [ + (SERVICE_SEND_PIN, {CONF_PIN: PIN}), + ( + SERVICE_SAVE_RECENT_CLIPS, + { + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + ), + ( + SERVICE_SAVE_VIDEO, + { + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + ), + ], +) +async def test_service_called_with_non_blink_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + service, + params, +) -> None: + """Test service calls with non blink device.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + other_domain = "NotBlink" + other_config_id = "555" + await hass.config_entries.async_add( + MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id + ) + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=other_config_id, + identifiers={ + (other_domain, 1), + }, + ) + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = {ATTR_DEVICE_ID: [device_entry.id]} + parameters.update(params) + + with pytest.raises(HomeAssistantError) as execinfo: + await hass.services.async_call( + DOMAIN, + service, + parameters, + blocking=True, + ) + + assert f"Device '{device_entry.id}' is not a blink device" in str(execinfo) + + +@pytest.mark.parametrize( + ("service", "params"), + [ + (SERVICE_SEND_PIN, {CONF_PIN: PIN}), + ( + SERVICE_SAVE_RECENT_CLIPS, + { + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + ), + ( + SERVICE_SAVE_VIDEO, + { + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + ), + ], +) +async def test_service_called_with_unloaded_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + service, + params, +) -> None: + """Test service calls with unloaded config entry.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await mock_config_entry.async_unload(hass) + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + + assert device_entry + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = {ATTR_DEVICE_ID: [device_entry.id]} + parameters.update(params) + + with pytest.raises(HomeAssistantError) as execinfo: + await hass.services.async_call( + DOMAIN, + service, + parameters, + blocking=True, + ) + + assert "Mock Title is not loaded" in str(execinfo) From 28a3d36bc1a2100503462ce18618a5524070b946 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 28 Nov 2023 09:11:32 +0100 Subject: [PATCH 807/982] Remove eq3btsmart integration (#94698) --- .coveragerc | 1 - CODEOWNERS | 1 - homeassistant/brands/eq3.json | 2 +- .../components/eq3btsmart/__init__.py | 1 - .../components/eq3btsmart/climate.py | 192 ------------------ homeassistant/components/eq3btsmart/const.py | 6 - .../components/eq3btsmart/manifest.json | 10 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 4 - requirements_test_all.txt | 1 - script/gen_requirements_all.py | 1 - 11 files changed, 1 insertion(+), 224 deletions(-) delete mode 100644 homeassistant/components/eq3btsmart/__init__.py delete mode 100644 homeassistant/components/eq3btsmart/climate.py delete mode 100644 homeassistant/components/eq3btsmart/const.py delete mode 100644 homeassistant/components/eq3btsmart/manifest.json diff --git a/.coveragerc b/.coveragerc index 884afdcf408..f15d36918ec 100644 --- a/.coveragerc +++ b/.coveragerc @@ -334,7 +334,6 @@ omit = homeassistant/components/epson/__init__.py homeassistant/components/epson/media_player.py homeassistant/components/epsonworkforce/sensor.py - homeassistant/components/eq3btsmart/climate.py homeassistant/components/escea/__init__.py homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py diff --git a/CODEOWNERS b/CODEOWNERS index 7b8a5ea5f87..60071eeeb61 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -354,7 +354,6 @@ build.json @home-assistant/supervisor /homeassistant/components/epson/ @pszafer /tests/components/epson/ @pszafer /homeassistant/components/epsonworkforce/ @ThaStealth -/homeassistant/components/eq3btsmart/ @rytilahti /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila /homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco diff --git a/homeassistant/brands/eq3.json b/homeassistant/brands/eq3.json index 4052afac277..f5b1c8aeb87 100644 --- a/homeassistant/brands/eq3.json +++ b/homeassistant/brands/eq3.json @@ -1,5 +1,5 @@ { "domain": "eq3", "name": "eQ-3", - "integrations": ["eq3btsmart", "maxcube"] + "integrations": ["maxcube"] } diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py deleted file mode 100644 index f32eba6944f..00000000000 --- a/homeassistant/components/eq3btsmart/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The eq3btsmart component.""" diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py deleted file mode 100644 index 700bc61293f..00000000000 --- a/homeassistant/components/eq3btsmart/climate.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Support for eQ-3 Bluetooth Smart thermostats.""" -from __future__ import annotations - -import logging -from typing import Any - -import eq3bt as eq3 -import voluptuous as vol - -from homeassistant.components.climate import ( - PLATFORM_SCHEMA, - PRESET_AWAY, - PRESET_BOOST, - PRESET_NONE, - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_DEVICES, - CONF_MAC, - PRECISION_HALVES, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import PRESET_CLOSED, PRESET_NO_HOLD, PRESET_OPEN, PRESET_PERMANENT_HOLD - -_LOGGER = logging.getLogger(__name__) - -STATE_BOOST = "boost" - -ATTR_STATE_WINDOW_OPEN = "window_open" -ATTR_STATE_VALVE = "valve" -ATTR_STATE_LOCKED = "is_locked" -ATTR_STATE_LOW_BAT = "low_battery" -ATTR_STATE_AWAY_END = "away_end" - -EQ_TO_HA_HVAC = { - eq3.Mode.Open: HVACMode.HEAT, - eq3.Mode.Closed: HVACMode.OFF, - eq3.Mode.Auto: HVACMode.AUTO, - eq3.Mode.Manual: HVACMode.HEAT, - eq3.Mode.Boost: HVACMode.AUTO, - eq3.Mode.Away: HVACMode.HEAT, -} - -HA_TO_EQ_HVAC = { - HVACMode.HEAT: eq3.Mode.Manual, - HVACMode.OFF: eq3.Mode.Closed, - HVACMode.AUTO: eq3.Mode.Auto, -} - -EQ_TO_HA_PRESET = { - eq3.Mode.Boost: PRESET_BOOST, - eq3.Mode.Away: PRESET_AWAY, - eq3.Mode.Manual: PRESET_PERMANENT_HOLD, - eq3.Mode.Auto: PRESET_NO_HOLD, - eq3.Mode.Open: PRESET_OPEN, - eq3.Mode.Closed: PRESET_CLOSED, -} - -HA_TO_EQ_PRESET = { - PRESET_BOOST: eq3.Mode.Boost, - PRESET_AWAY: eq3.Mode.Away, - PRESET_PERMANENT_HOLD: eq3.Mode.Manual, - PRESET_NO_HOLD: eq3.Mode.Auto, - PRESET_OPEN: eq3.Mode.Open, - PRESET_CLOSED: eq3.Mode.Closed, -} - - -DEVICE_SCHEMA = vol.Schema({vol.Required(CONF_MAC): cv.string}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_DEVICES): vol.Schema({cv.string: DEVICE_SCHEMA})} -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the eQ-3 BLE thermostats.""" - devices = [] - - for name, device_cfg in config[CONF_DEVICES].items(): - mac = device_cfg[CONF_MAC] - devices.append(EQ3BTSmartThermostat(mac, name)) - - add_entities(devices, True) - - -class EQ3BTSmartThermostat(ClimateEntity): - """Representation of an eQ-3 Bluetooth Smart thermostat.""" - - _attr_hvac_modes = list(HA_TO_EQ_HVAC) - _attr_precision = PRECISION_HALVES - _attr_preset_modes = list(HA_TO_EQ_PRESET) - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) - _attr_temperature_unit = UnitOfTemperature.CELSIUS - - def __init__(self, mac: str, name: str) -> None: - """Initialize the thermostat.""" - # We want to avoid name clash with this module. - self._attr_name = name - self._attr_unique_id = format_mac(mac) - self._thermostat = eq3.Thermostat(mac) - - @property - def available(self) -> bool: - """Return if thermostat is available.""" - return self._thermostat.mode >= 0 - - @property - def current_temperature(self): - """Can not report temperature, so return target_temperature.""" - return self.target_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._thermostat.target_temperature - - def set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - return - self._thermostat.target_temperature = temperature - - @property - def hvac_mode(self) -> HVACMode: - """Return the current operation mode.""" - if self._thermostat.mode < 0: - return HVACMode.OFF - return EQ_TO_HA_HVAC[self._thermostat.mode] - - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set operation mode.""" - self._thermostat.mode = HA_TO_EQ_HVAC[hvac_mode] - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._thermostat.min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._thermostat.max_temp - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the device specific state attributes.""" - return { - ATTR_STATE_AWAY_END: self._thermostat.away_end, - ATTR_STATE_LOCKED: self._thermostat.locked, - ATTR_STATE_LOW_BAT: self._thermostat.low_battery, - ATTR_STATE_VALVE: self._thermostat.valve_state, - ATTR_STATE_WINDOW_OPEN: self._thermostat.window_open, - } - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp. - - Requires ClimateEntityFeature.PRESET_MODE. - """ - return EQ_TO_HA_PRESET.get(self._thermostat.mode) - - def set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode.""" - if preset_mode == PRESET_NONE: - self.set_hvac_mode(HVACMode.HEAT) - self._thermostat.mode = HA_TO_EQ_PRESET[preset_mode] - - def update(self) -> None: - """Update the data from the thermostat.""" - - try: - self._thermostat.update() - except eq3.BackendException as ex: - _LOGGER.warning("Updating the state failed: %s", ex) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py deleted file mode 100644 index af90acbde55..00000000000 --- a/homeassistant/components/eq3btsmart/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants for EQ3 Bluetooth Smart Radiator Valves.""" - -PRESET_PERMANENT_HOLD = "permanent_hold" -PRESET_NO_HOLD = "no_hold" -PRESET_OPEN = "open" -PRESET_CLOSED = "closed" diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json deleted file mode 100644 index 8a976b25c7a..00000000000 --- a/homeassistant/components/eq3btsmart/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "eq3btsmart", - "name": "eQ-3 Bluetooth Smart Thermostats", - "codeowners": ["@rytilahti"], - "dependencies": ["bluetooth_adapters"], - "documentation": "https://www.home-assistant.io/integrations/eq3btsmart", - "iot_class": "local_polling", - "loggers": ["bleak", "eq3bt"], - "requirements": ["construct==2.10.68", "python-eq3bt==0.2"] -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bfd7a869089..312a2838051 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1552,12 +1552,6 @@ "eq3": { "name": "eQ-3", "integrations": { - "eq3btsmart": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling", - "name": "eQ-3 Bluetooth Smart Thermostats" - }, "maxcube": { "integration_type": "hub", "config_flow": false, diff --git a/requirements_all.txt b/requirements_all.txt index bb646832361..77ce0c4b237 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -636,7 +636,6 @@ concord232==0.15 # homeassistant.components.upc_connect connect-box==0.2.8 -# homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio construct==2.10.68 @@ -2130,9 +2129,6 @@ python-digitalocean==1.13.2 # homeassistant.components.ecobee python-ecobee-api==0.2.17 -# homeassistant.components.eq3btsmart -# python-eq3bt==0.2 - # homeassistant.components.etherscan python-etherscan-api==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0c22d7ed25..dc49c358a8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -517,7 +517,6 @@ colorlog==6.7.0 # homeassistant.components.color_extractor colorthief==0.2.1 -# homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio construct==2.10.68 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f62d6e936a7..f6835fdbaf1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -32,7 +32,6 @@ COMMENT_REQUIREMENTS = ( "pybluez", "pycocotools", "pycups", - "python-eq3bt", "python-gammu", "python-lirc", "pyuserinput", From f149c809c211acd211f8abf948dccef0d4c03e9b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Nov 2023 10:03:57 +0100 Subject: [PATCH 808/982] Add field description for Roku host (#104631) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/roku/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 818b43930f4..9eef366163e 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -6,6 +6,9 @@ "description": "Enter your Roku information.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Roku device to control." } }, "discovery_confirm": { From d1463a81d38058d2c2692a7759ce5db77722df9e Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 28 Nov 2023 10:19:20 +0100 Subject: [PATCH 809/982] Fix async issue in ViCare climate entity (#104619) * use async executor * use async executor * Revert "use async executor" This reverts commit 4913e05b1c6f6289018d55bcc8f16cf6391e4121. * Revert "use async executor" This reverts commit 40abc10362bc0910ebb9649e664d3daaeed939f5. * fix async issue --- homeassistant/components/vicare/climate.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index a7a2fb7e277..c1e04e1d1b2 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -155,7 +155,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._current_program = None self._attr_translation_key = translation_key - async def async_update(self) -> None: + def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" try: _room_temperature = None @@ -206,15 +206,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._current_action = False # Update the specific device attributes with suppress(PyViCareNotSupportedFeatureError): - burners = await self.hass.async_add_executor_job(get_burners, self._api) - for burner in burners: + for burner in get_burners(self._api): self._current_action = self._current_action or burner.getActive() with suppress(PyViCareNotSupportedFeatureError): - compressors = await self.hass.async_add_executor_job( - get_compressors, self._api - ) - for compressor in compressors: + for compressor in get_compressors(self._api): self._current_action = ( self._current_action or compressor.getActive() ) From 1ef97ab6f891f16c15626b1ea81c76ff9ac0797a Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 28 Nov 2023 10:53:59 +0100 Subject: [PATCH 810/982] Set min, max, and step for ViCare number entities (#104593) Co-authored-by: Robert Resch --- .../components/vicare/binary_sensor.py | 4 +- homeassistant/components/vicare/button.py | 2 +- homeassistant/components/vicare/number.py | 68 +++++++++++++------ homeassistant/components/vicare/sensor.py | 4 +- 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index bab132121f6..f92f24d01ce 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -135,7 +135,7 @@ async def _entities_from_descriptions( hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) - if entity is not None: + if entity: entities.append(entity) @@ -156,7 +156,7 @@ async def async_setup_entry( hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) - if entity is not None: + if entity: entities.append(entity) circuits = await hass.async_add_executor_job(get_circuits, api) diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 54b183bcc33..d0e50b5f772 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -79,7 +79,7 @@ async def async_setup_entry( hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) - if entity is not None: + if entity: entities.append(entity) async_add_entities(entities) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 577bb8257ea..45abd0a5cda 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -10,7 +10,7 @@ from typing import Any from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareHeatingDevice import ( - HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, + HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, @@ -38,6 +38,9 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM """Describes ViCare number entity.""" value_setter: Callable[[PyViCareDevice, float], Any] | None = None + min_value_getter: Callable[[PyViCareDevice], float | None] | None = None + max_value_getter: Callable[[PyViCareDevice], float | None] | None = None + stepping_getter: Callable[[PyViCareDevice], float | None] | None = None CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( @@ -46,11 +49,14 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( translation_key="heating_curve_shift", icon="mdi:plus-minus-variant", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getHeatingCurveShift(), value_setter=lambda api, shift: ( api.setHeatingCurve(shift, api.getHeatingCurveSlope()) ), - native_unit_of_measurement=UnitOfTemperature.CELSIUS, + min_value_getter=lambda api: api.getHeatingCurveShiftMin(), + max_value_getter=lambda api: api.getHeatingCurveShiftMax(), + stepping_getter=lambda api: api.getHeatingCurveShiftStepping(), native_min_value=-13, native_max_value=40, native_step=1, @@ -64,6 +70,9 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( value_setter=lambda api, slope: ( api.setHeatingCurve(api.getHeatingCurveShift(), slope) ), + min_value_getter=lambda api: api.getHeatingCurveSlopeMin(), + max_value_getter=lambda api: api.getHeatingCurveSlopeMax(), + stepping_getter=lambda api: api.getHeatingCurveSlopeStepping(), native_min_value=0.2, native_max_value=3.5, native_step=0.1, @@ -72,7 +81,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( def _build_entity( - vicare_api: PyViCareHeatingDeviceWithComponent, + vicare_api: PyViCareHeatingDeviceComponent, device_config: PyViCareDeviceConfig, entity_description: ViCareNumberEntityDescription, ) -> ViCareNumber | None: @@ -92,23 +101,21 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare number devices.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - circuits = await hass.async_add_executor_job(get_circuits, api) - entities: list[ViCareNumber] = [] - try: - for circuit in circuits: - for description in CIRCUIT_ENTITY_DESCRIPTIONS: - entity = await hass.async_add_executor_job( - _build_entity, - circuit, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, - ) - if entity is not None: - entities.append(entity) - except PyViCareNotSupportedFeatureError: - _LOGGER.debug("No circuits found") + api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + + circuits = await hass.async_add_executor_job(get_circuits, api) + for circuit in circuits: + for description in CIRCUIT_ENTITY_DESCRIPTIONS: + entity = await hass.async_add_executor_job( + _build_entity, + circuit, + device_config, + description, + ) + if entity: + entities.append(entity) async_add_entities(entities) @@ -120,7 +127,7 @@ class ViCareNumber(ViCareEntity, NumberEntity): def __init__( self, - api: PyViCareHeatingDeviceWithComponent, + api: PyViCareHeatingDeviceComponent, device_config: PyViCareDeviceConfig, description: ViCareNumberEntityDescription, ) -> None: @@ -146,6 +153,20 @@ class ViCareNumber(ViCareEntity, NumberEntity): self._attr_native_value = self.entity_description.value_getter( self._api ) + if min_value := _get_value( + self.entity_description.min_value_getter, self._api + ): + self._attr_native_min_value = min_value + + if max_value := _get_value( + self.entity_description.max_value_getter, self._api + ): + self._attr_native_max_value = max_value + + if stepping_value := _get_value( + self.entity_description.stepping_getter, self._api + ): + self._attr_native_step = stepping_value except RequestConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: @@ -154,3 +175,10 @@ class ViCareNumber(ViCareEntity, NumberEntity): _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + + +def _get_value( + fn: Callable[[PyViCareDevice], float | None] | None, + api: PyViCareHeatingDeviceComponent, +) -> float | None: + return None if fn is None else fn(api) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index caf1151f5ec..bfad8b107cb 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -624,7 +624,7 @@ async def _entities_from_descriptions( hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) - if entity is not None: + if entity: entities.append(entity) @@ -647,7 +647,7 @@ async def async_setup_entry( device_config, description, ) - if entity is not None: + if entity: entities.append(entity) circuits = await hass.async_add_executor_job(get_circuits, api) From 2d2e215e2ca7229643dc62bd31d082c4846367de Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 28 Nov 2023 12:08:12 +0100 Subject: [PATCH 811/982] Fix Tractive switch availability (#104502) --- homeassistant/components/tractive/__init__.py | 23 +++++++++++++------ homeassistant/components/tractive/const.py | 9 +++++++- homeassistant/components/tractive/switch.py | 13 +++++++---- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 300d7ebafc7..8dd0ed8e91b 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -24,11 +24,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ATTR_ACTIVITY_LABEL, - ATTR_BUZZER, ATTR_CALORIES, ATTR_DAILY_GOAL, - ATTR_LED, - ATTR_LIVE_TRACKING, ATTR_MINUTES_ACTIVE, ATTR_MINUTES_DAY_SLEEP, ATTR_MINUTES_NIGHT_SLEEP, @@ -40,10 +37,12 @@ from .const import ( DOMAIN, RECONNECT_INTERVAL, SERVER_UNAVAILABLE, + SWITCH_KEY_MAP, TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, + TRACKER_SWITCH_STATUS_UPDATED, TRACKER_WELLNESS_STATUS_UPDATED, ) @@ -225,13 +224,16 @@ class TractiveClient: ): self._last_hw_time = event["hardware"]["time"] self._send_hardware_update(event) - if ( "position" in event and self._last_pos_time != event["position"]["time"] ): self._last_pos_time = event["position"]["time"] self._send_position_update(event) + # If any key belonging to the switch is present in the event, + # we send a switch status update + if bool(set(SWITCH_KEY_MAP.values()).intersection(event)): + self._send_switch_update(event) except aiotractive.exceptions.UnauthorizedError: self._config_entry.async_start_reauth(self._hass) await self.unsubscribe() @@ -266,14 +268,21 @@ class TractiveClient: ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], ATTR_TRACKER_STATE: event["tracker_state"].lower(), ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", - ATTR_LIVE_TRACKING: event.get("live_tracking", {}).get("active"), - ATTR_BUZZER: event.get("buzzer_control", {}).get("active"), - ATTR_LED: event.get("led_control", {}).get("active"), } self._dispatch_tracker_event( TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload ) + def _send_switch_update(self, event: dict[str, Any]) -> None: + # Sometimes the event contains data for all switches, sometimes only for one. + payload = {} + for switch, key in SWITCH_KEY_MAP.items(): + if switch_data := event.get(key): + payload[switch] = switch_data["active"] + self._dispatch_tracker_event( + TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload + ) + def _send_activity_update(self, event: dict[str, Any]) -> None: payload = { ATTR_MINUTES_ACTIVE: event["progress"]["achieved_minutes"], diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index 254a8c274f3..acb4f6f7487 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -26,9 +26,16 @@ CLIENT_ID = "625e5349c3c3b41c28a669f1" CLIENT = "client" TRACKABLES = "trackables" +TRACKER_ACTIVITY_STATUS_UPDATED = f"{DOMAIN}_tracker_activity_updated" TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" -TRACKER_ACTIVITY_STATUS_UPDATED = f"{DOMAIN}_tracker_activity_updated" +TRACKER_SWITCH_STATUS_UPDATED = f"{DOMAIN}_tracker_switch_updated" TRACKER_WELLNESS_STATUS_UPDATED = f"{DOMAIN}_tracker_wellness_updated" SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable" + +SWITCH_KEY_MAP = { + ATTR_LIVE_TRACKING: "live_tracking", + ATTR_BUZZER: "buzzer_control", + ATTR_LED: "led_control", +} diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 55acdb9bdcd..58c82bd6514 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -21,7 +21,7 @@ from .const import ( CLIENT, DOMAIN, TRACKABLES, - TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_SWITCH_STATUS_UPDATED, ) from .entity import TractiveEntity @@ -99,11 +99,10 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): client, item.trackable, item.tracker_details, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + f"{TRACKER_SWITCH_STATUS_UPDATED}-{item.tracker_details['_id']}", ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" - self._attr_available = False self._tracker = item.tracker self._method = getattr(self, description.method) self.entity_description = description @@ -111,9 +110,15 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): @callback def handle_status_update(self, event: dict[str, Any]) -> None: """Handle status update.""" + if self.entity_description.key not in event: + return + + # We received an event, so the service is online and the switch entities should + # be available. + self._attr_available = True self._attr_is_on = event[self.entity_description.key] - super().handle_status_update(event) + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on a switch.""" From 2a4a5d0a07ab5d881e438e15d51724f58d821a55 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Nov 2023 12:09:12 +0100 Subject: [PATCH 812/982] Update cryptography to 41.0.7 (#104632) --- 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 bae4d616903..9ea82cb3e75 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.15.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.5 +cryptography==41.0.7 dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 diff --git a/pyproject.toml b/pyproject.toml index 5661b7ca130..b05f5f5f9dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "lru-dict==1.2.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.5", + "cryptography==41.0.7", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", "orjson==3.9.9", diff --git a/requirements.txt b/requirements.txt index 1d1837d9bce..f26c1a84e81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 PyJWT==2.8.0 -cryptography==41.0.5 +cryptography==41.0.7 pyOpenSSL==23.2.0 orjson==3.9.9 packaging>=23.1 From b8cc3349be663f55ba1501077cb2d04c0f72540a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Nov 2023 04:01:12 -0800 Subject: [PATCH 813/982] Add To-do due date and description fields (#104128) * Add To-do due date and description fields * Fix due date schema * Revert devcontainer change * Split due date and due date time * Add tests for config validation function * Add timezone converstion tests * Add local todo due date/time and description implementation * Revert configuration * Revert test changes * Add comments for the todo item field description * Rename function _validate_supported_features * Fix issues in items factory * Readability improvements * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Rename CONF to ATTR usages * Simplify local time validator * Rename TodoListEntityFeature fields for setting extended fields * Remove duplicate validations * Update subscribe test * Fix local_todo tests --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/local_todo/todo.py | 5 + homeassistant/components/todo/__init__.py | 155 +++++++++- homeassistant/components/todo/const.py | 8 + homeassistant/components/todo/services.yaml | 24 ++ homeassistant/components/todo/strings.json | 24 ++ tests/components/local_todo/test_todo.py | 72 ++++- tests/components/todo/test_init.py | 295 +++++++++++++++++++- 7 files changed, 554 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index cd30c2eeebe..c5cf25a8c2e 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -90,6 +90,9 @@ class LocalTodoListEntity(TodoListEntity): | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.MOVE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) _attr_should_poll = False @@ -115,6 +118,8 @@ class LocalTodoListEntity(TodoListEntity): status=ICS_TODO_STATUS_MAP.get( item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION ), + due=item.due, + description=item.description, ) for item in self._calendar.todos ] diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index be3c0b57593..814138dcb7f 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -1,6 +1,6 @@ """The todo integration.""" -from collections.abc import Callable +from collections.abc import Callable, Iterable import dataclasses import datetime import logging @@ -28,9 +28,18 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType -from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature +from .const import ( + ATTR_DESCRIPTION, + ATTR_DUE, + ATTR_DUE_DATE, + ATTR_DUE_DATE_TIME, + DOMAIN, + TodoItemStatus, + TodoListEntityFeature, +) _LOGGER = logging.getLogger(__name__) @@ -39,6 +48,65 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) ENTITY_ID_FORMAT = DOMAIN + ".{}" +@dataclasses.dataclass +class TodoItemFieldDescription: + """A description of To-do item fields and validation requirements.""" + + service_field: str + """Field name for service calls.""" + + todo_item_field: str + """Field name for TodoItem.""" + + validation: Callable[[Any], Any] + """Voluptuous validation function.""" + + required_feature: TodoListEntityFeature + """Entity feature that enables this field.""" + + +TODO_ITEM_FIELDS = [ + TodoItemFieldDescription( + service_field=ATTR_DUE_DATE, + validation=cv.date, + todo_item_field=ATTR_DUE, + required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + ), + TodoItemFieldDescription( + service_field=ATTR_DUE_DATE_TIME, + validation=vol.All(cv.datetime, dt_util.as_local), + todo_item_field=ATTR_DUE, + required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + ), + TodoItemFieldDescription( + service_field=ATTR_DESCRIPTION, + validation=cv.string, + todo_item_field=ATTR_DESCRIPTION, + required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + ), +] + +TODO_ITEM_FIELD_SCHEMA = { + vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS +} +TODO_ITEM_FIELD_VALIDATIONS = [ + cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATE_TIME) +] + + +def _validate_supported_features( + supported_features: int | None, call_data: dict[str, Any] +) -> None: + """Validate service call fields against entity supported features.""" + for desc in TODO_ITEM_FIELDS: + if desc.service_field not in call_data: + continue + if not supported_features or not supported_features & desc.required_feature: + raise ValueError( + f"Entity does not support setting field '{desc.service_field}'" + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Todo entities.""" component = hass.data[DOMAIN] = EntityComponent[TodoListEntity]( @@ -53,9 +121,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( "add_item", - { - vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), - }, + vol.All( + cv.make_entity_service_schema( + { + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), + **TODO_ITEM_FIELD_SCHEMA, + } + ), + *TODO_ITEM_FIELD_VALIDATIONS, + ), _async_add_todo_item, required_features=[TodoListEntityFeature.CREATE_TODO_ITEM], ) @@ -69,9 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Optional("status"): vol.In( {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED}, ), + **TODO_ITEM_FIELD_SCHEMA, } ), - cv.has_at_least_one_key("rename", "status"), + *TODO_ITEM_FIELD_VALIDATIONS, + cv.has_at_least_one_key( + "rename", "status", *[desc.service_field for desc in TODO_ITEM_FIELDS] + ), ), _async_update_todo_item, required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], @@ -135,6 +213,20 @@ class TodoItem: status: TodoItemStatus | None = None """A status or confirmation of the To-do item.""" + due: datetime.date | datetime.datetime | None = None + """The date and time that a to-do is expected to be completed. + + This field may be a date or datetime depending whether the entity feature + DUE_DATE or DUE_DATETIME are set. + """ + + description: str | None = None + """A more complete description of than that provided by the summary. + + This field may be set when TodoListEntityFeature.DESCRIPTION is supported by + the entity. + """ + class TodoListEntity(Entity): """An entity that represents a To-do list.""" @@ -262,6 +354,19 @@ async def websocket_handle_subscribe_todo_items( entity.async_update_listeners() +def _api_items_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + """Convert CalendarEvent dataclass items to dictionary of attributes.""" + result: dict[str, str] = {} + for name, value in obj: + if value is None: + continue + if isinstance(value, (datetime.date, datetime.datetime)): + result[name] = value.isoformat() + else: + result[name] = str(value) + return result + + @websocket_api.websocket_command( { vol.Required("type"): "todo/item/list", @@ -285,7 +390,13 @@ async def websocket_handle_todo_item_list( items: list[TodoItem] = entity.todo_items or [] connection.send_message( websocket_api.result_message( - msg["id"], {"items": [dataclasses.asdict(item) for item in items]} + msg["id"], + { + "items": [ + dataclasses.asdict(item, dict_factory=_api_items_factory) + for item in items + ] + }, ) ) @@ -342,8 +453,17 @@ def _find_by_uid_or_summary( async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Add an item to the To-do list.""" + _validate_supported_features(entity.supported_features, call.data) await entity.async_create_todo_item( - item=TodoItem(summary=call.data["item"], status=TodoItemStatus.NEEDS_ACTION) + item=TodoItem( + summary=call.data["item"], + status=TodoItemStatus.NEEDS_ACTION, + **{ + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + }, + ) ) @@ -354,11 +474,20 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> if not found: raise ValueError(f"Unable to find To-do item '{item}'") - update_item = TodoItem( - uid=found.uid, summary=call.data.get("rename"), status=call.data.get("status") - ) + _validate_supported_features(entity.supported_features, call.data) - await entity.async_update_todo_item(item=update_item) + await entity.async_update_todo_item( + item=TodoItem( + uid=found.uid, + summary=call.data.get("rename"), + status=call.data.get("status"), + **{ + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + }, + ) + ) async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: @@ -378,7 +507,7 @@ async def _async_get_todo_items( """Return items in the To-do list.""" return { "items": [ - dataclasses.asdict(item) + dataclasses.asdict(item, dict_factory=_api_items_factory) for item in entity.todo_items or () if not (statuses := call.data.get("status")) or item.status in statuses ] diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index 5a8a6e54e8f..95e190cb3e3 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -4,6 +4,11 @@ from enum import IntFlag, StrEnum DOMAIN = "todo" +ATTR_DUE = "due" +ATTR_DUE_DATE = "due_date" +ATTR_DUE_DATE_TIME = "due_date_time" +ATTR_DESCRIPTION = "description" + class TodoListEntityFeature(IntFlag): """Supported features of the To-do List entity.""" @@ -12,6 +17,9 @@ class TodoListEntityFeature(IntFlag): DELETE_TODO_ITEM = 2 UPDATE_TODO_ITEM = 4 MOVE_TODO_ITEM = 8 + SET_DUE_DATE_ON_ITEM = 16 + SET_DUE_DATETIME_ON_ITEM = 32 + SET_DESCRIPTION_ON_ITEM = 64 class TodoItemStatus(StrEnum): diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 5474efefbdf..390aa82753a 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -25,6 +25,18 @@ add_item: example: "Submit income tax return" selector: text: + due_date: + example: "2023-11-17" + selector: + date: + due_date_time: + example: "2023-11-17 13:30:00" + selector: + datetime: + description: + example: "A more complete description of the to-do item than that provided by the summary." + selector: + text: update_item: target: entity: @@ -49,6 +61,18 @@ update_item: options: - needs_action - completed + due_date: + example: "2023-11-17" + selector: + date: + due_date_time: + example: "2023-11-17 13:30:00" + selector: + datetime: + description: + example: "A more complete description of the to-do item than that provided by the summary." + selector: + text: remove_item: target: entity: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index a651a161763..bca32f850eb 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -23,6 +23,18 @@ "item": { "name": "Item name", "description": "The name that represents the to-do item." + }, + "due_date": { + "name": "Due date", + "description": "The date the to-do item is expected to be completed." + }, + "due_date_time": { + "name": "Due date time", + "description": "The date and time the to-do item is expected to be completed." + }, + "description": { + "name": "Description", + "description": "A more complete description of the to-do item than provided by the item name." } } }, @@ -41,6 +53,18 @@ "status": { "name": "Set status", "description": "A status or confirmation of the to-do item." + }, + "due_date": { + "name": "Due date", + "description": "The date the to-do item is expected to be completed." + }, + "due_date_time": { + "name": "Due date time", + "description": "The date and time the to-do item is expected to be completed." + }, + "description": { + "name": "Description", + "description": "A more complete description of the to-do item than provided by the item name." } } }, diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 5e6aff9cbf3..b2c79ef4bd1 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable import textwrap +from typing import Any import pytest @@ -58,11 +59,31 @@ async def ws_move_item( return move +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({}, {}), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ( + {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + ), + ({"description": "Additional detail"}, {"description": "Additional detail"}), + ], +) async def test_add_item( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], ) -> None: """Test adding a todo item.""" @@ -73,7 +94,7 @@ async def test_add_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "replace batteries"}, + {"item": "replace batteries", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -82,6 +103,8 @@ async def test_add_item( assert len(items) == 1 assert items[0]["summary"] == "replace batteries" assert items[0]["status"] == "needs_action" + for k, v in expected_item_data.items(): + assert items[0][k] == v assert "uid" in items[0] state = hass.states.get(TEST_ENTITY) @@ -89,16 +112,30 @@ async def test_add_item( assert state.state == "1" +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({}, {}), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ( + {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + ), + ({"description": "Additional detail"}, {"description": "Additional detail"}), + ], +) async def test_remove_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], ) -> None: """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "replace batteries"}, + {"item": "replace batteries", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -107,6 +144,8 @@ async def test_remove_item( assert len(items) == 1 assert items[0]["summary"] == "replace batteries" assert items[0]["status"] == "needs_action" + for k, v in expected_item_data.items(): + assert items[0][k] == v assert "uid" in items[0] state = hass.states.get(TEST_ENTITY) @@ -168,10 +207,30 @@ async def test_bulk_remove( assert state.state == "0" +@pytest.mark.parametrize( + ("item_data", "expected_item_data", "expected_state"), + [ + ({"status": "completed"}, {"status": "completed"}, "0"), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), + ( + {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + "1", + ), + ( + {"description": "Additional detail"}, + {"description": "Additional detail"}, + "1", + ), + ], +) async def test_update_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], + expected_state: str, ) -> None: """Test updating a todo item.""" @@ -199,21 +258,22 @@ async def test_update_item( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": item["uid"], "status": "completed"}, + {"item": item["uid"], **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) - # Verify item is marked as completed + # Verify item is updated items = await ws_get_items() assert len(items) == 1 item = items[0] assert item["summary"] == "soda" - assert item["status"] == "completed" + for k, v in expected_item_data.items(): + assert items[0][k] == v state = hass.states.get(TEST_ENTITY) assert state - assert state.state == "0" + assert state.state == expected_state async def test_rename( diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index a65cce27349..0071d4ada86 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -1,8 +1,10 @@ """Tests for the todo integration.""" from collections.abc import Generator +import datetime from typing import Any from unittest.mock import AsyncMock +import zoneinfo import pytest import voluptuous as vol @@ -43,6 +45,8 @@ ITEM_2 = { "summary": "Item #2", "status": "completed", } +TEST_TIMEZONE = zoneinfo.ZoneInfo("America/Regina") +TEST_OFFSET = "-06:00" class MockFlow(ConfigFlow): @@ -108,6 +112,12 @@ def mock_setup_integration(hass: HomeAssistant) -> None: ) +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + async def create_mock_platform( hass: HomeAssistant, entities: list[TodoListEntity], @@ -263,7 +273,7 @@ async def test_unsupported_websocket( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: - """Test a To-do list that does not support features.""" + """Test a To-do list for an entity that does not exist.""" entity1 = TodoListEntity() entity1.entity_id = "todo.entity1" @@ -327,23 +337,42 @@ async def test_add_item_service_raises( @pytest.mark.parametrize( - ("item_data", "expected_error"), + ("item_data", "expected_exception", "expected_error"), [ - ({}, "required key not provided"), - ({"item": ""}, "length of value must be at least 1"), + ({}, vol.Invalid, "required key not provided"), + ({"item": ""}, vol.Invalid, "length of value must be at least 1"), + ( + {"item": "Submit forms", "description": "Submit tax forms"}, + ValueError, + "does not support setting field 'description'", + ), + ( + {"item": "Submit forms", "due_date": "2023-11-17"}, + ValueError, + "does not support setting field 'due_date'", + ), + ( + { + "item": "Submit forms", + "due_date_time": f"2023-11-17T17:00:00{TEST_OFFSET}", + }, + ValueError, + "does not support setting field 'due_date_time'", + ), ], ) async def test_add_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, item_data: dict[str, Any], + expected_exception: str, expected_error: str, ) -> None: """Test invalid input to the add item service.""" await create_mock_platform(hass, [test_entity]) - with pytest.raises(vol.Invalid, match=expected_error): + with pytest.raises(expected_exception, match=expected_error): await hass.services.async_call( DOMAIN, "add_item", @@ -353,6 +382,82 @@ async def test_add_item_service_invalid_input( ) +@pytest.mark.parametrize( + ("supported_entity_feature", "item_data", "expected_item"), + ( + ( + TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + {"item": "New item", "due_date": "2023-11-13"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.date(2023, 11, 13), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 17, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_date_time": "2023-11-13T17:00:00+00:00"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 11, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_date_time": "2023-11-13"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 0, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + {"item": "New item", "description": "Submit revised draft"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + description="Submit revised draft", + ), + ), + ), +) +async def test_add_item_service_extended_fields( + hass: HomeAssistant, + test_entity: TodoListEntity, + supported_entity_feature: int, + item_data: dict[str, Any], + expected_item: TodoItem, +) -> None: + """Test adding an item in a To-do list.""" + + test_entity._attr_supported_features |= supported_entity_feature + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "New item", **item_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_create_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item == expected_item + + async def test_update_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, @@ -555,6 +660,82 @@ async def test_update_item_service_invalid_input( ) +@pytest.mark.parametrize( + ("update_data"), + [ + ({"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}), + ({"due_date": "2023-11-13"}), + ({"description": "Submit revised draft"}), + ], +) +async def test_update_todo_item_field_unsupported( + hass: HomeAssistant, + test_entity: TodoListEntity, + update_data: dict[str, Any], +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(ValueError, match="does not support"): + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", **update_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("supported_entity_feature", "update_data", "expected_update"), + ( + ( + TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + {"due_date": "2023-11-13"}, + TodoItem(uid="1", due=datetime.date(2023, 11, 13)), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + TodoItem( + uid="1", + due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + {"description": "Submit revised draft"}, + TodoItem(uid="1", description="Submit revised draft"), + ), + ), +) +async def test_update_todo_item_extended_fields( + hass: HomeAssistant, + test_entity: TodoListEntity, + supported_entity_feature: int, + update_data: dict[str, Any], + expected_update: TodoItem, +) -> None: + """Test updating an item in a To-do list.""" + + test_entity._attr_supported_features |= supported_entity_feature + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", **update_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item == expected_update + + async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, @@ -971,8 +1152,20 @@ async def test_subscribe( event_message = msg["event"] assert event_message == { "items": [ - {"summary": "Item #1", "uid": "1", "status": "needs_action"}, - {"summary": "Item #2", "uid": "2", "status": "completed"}, + { + "summary": "Item #1", + "uid": "1", + "status": "needs_action", + "due": None, + "description": None, + }, + { + "summary": "Item #2", + "uid": "2", + "status": "completed", + "due": None, + "description": None, + }, ] } test_entity._attr_todo_items = [ @@ -985,9 +1178,27 @@ async def test_subscribe( event_message = msg["event"] assert event_message == { "items": [ - {"summary": "Item #1", "uid": "1", "status": "needs_action"}, - {"summary": "Item #2", "uid": "2", "status": "completed"}, - {"summary": "Item #3", "uid": "3", "status": "needs_action"}, + { + "summary": "Item #1", + "uid": "1", + "status": "needs_action", + "due": None, + "description": None, + }, + { + "summary": "Item #2", + "uid": "2", + "status": "completed", + "due": None, + "description": None, + }, + { + "summary": "Item #3", + "uid": "3", + "status": "needs_action", + "due": None, + "description": None, + }, ] } @@ -1023,3 +1234,67 @@ async def test_subscribe_entity_does_not_exist( "code": "invalid_entity_id", "message": "To-do list entity not found: todo.unknown", } + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({"due": datetime.date(2023, 11, 17)}, {"due": "2023-11-17"}), + ( + {"due": datetime.datetime(2023, 11, 17, 17, 0, 0, tzinfo=TEST_TIMEZONE)}, + {"due": f"2023-11-17T17:00:00{TEST_OFFSET}"}, + ), + ({"description": "Some description"}, {"description": "Some description"}), + ], +) +async def test_list_todo_items_extended_fields( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, + item_data: dict[str, Any], + expected_item_data: dict[str, Any], +) -> None: + """Test listing items in a To-do list with extended fields.""" + + test_entity._attr_todo_items = [ + TodoItem( + **ITEM_1, + **item_data, + ), + ] + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 1, "type": "todo/item/list", "entity_id": "todo.entity1"} + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + assert resp.get("result") == { + "items": [ + { + **ITEM_1, + **expected_item_data, + }, + ] + } + + result = await hass.services.async_call( + DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.entity1"}, + blocking=True, + return_response=True, + ) + assert result == { + "todo.entity1": { + "items": [ + { + **ITEM_1, + **expected_item_data, + }, + ] + } + } From 9dc5d4a1bbbb80d9878c87b103c167207f9c452c Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:23:51 +0100 Subject: [PATCH 814/982] Update stookwijzer api to atlas leefomgeving (#103323) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/stookwijzer/const.py | 6 +++--- homeassistant/components/stookwijzer/diagnostics.py | 9 +++++---- homeassistant/components/stookwijzer/manifest.json | 2 +- homeassistant/components/stookwijzer/sensor.py | 6 +++--- homeassistant/components/stookwijzer/strings.json | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 17 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 1a125da6a6b..d1ac46148a7 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -10,6 +10,6 @@ LOGGER = logging.getLogger(__package__) class StookwijzerState(StrEnum): """Stookwijzer states for sensor entity.""" - BLUE = "blauw" - ORANGE = "oranje" - RED = "rood" + CODE_YELLOW = "code_yellow" + CODE_ORANGE = "code_orange" + CODE_RED = "code_red" diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index e29606cb191..85996bb6394 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -24,8 +24,9 @@ async def async_get_config_entry_diagnostics( return { "state": client.state, "last_updated": last_updated, - "lqi": client.lqi, - "windspeed": client.windspeed, - "weather": client.weather, - "concentrations": client.concentrations, + "alert": client.alert, + "air_quality_index": client.lki, + "windspeed_bft": client.windspeed_bft, + "windspeed_ms": client.windspeed_ms, + "forecast": client.forecast, } diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index dbf902b1e1e..91504ef923f 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.3.0"] + "requirements": ["stookwijzer==1.4.2"] } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 312f8bdd02d..e8d03499a8e 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( class StookwijzerSensor(SensorEntity): """Defines a Stookwijzer binary sensor.""" - _attr_attribution = "Data provided by stookwijzer.nu" + _attr_attribution = "Data provided by atlasleefomgeving.nl" _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True _attr_name = None @@ -43,9 +43,9 @@ class StookwijzerSensor(SensorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{entry.entry_id}")}, name="Stookwijzer", - manufacturer="stookwijzer.nu", + manufacturer="Atlas Leefomgeving", entry_type=DeviceEntryType.SERVICE, - configuration_url="https://www.stookwijzer.nu", + configuration_url="https://www.atlasleefomgeving.nl/stookwijzer", ) def update(self) -> None: diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 549673165ec..62006f878c8 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -13,9 +13,9 @@ "sensor": { "stookwijzer": { "state": { - "blauw": "Blue", - "oranje": "Orange", - "rood": "Red" + "code_yellow": "Code yellow", + "code_orange": "Code orange", + "code_red": "Code red" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 77ce0c4b237..d2505e5726a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2522,7 +2522,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.3.0 +stookwijzer==1.4.2 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc49c358a8c..2098110ca7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1886,7 +1886,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.3.0 +stookwijzer==1.4.2 # homeassistant.components.huawei_lte # homeassistant.components.solaredge From 61a5c0de5ed1818edfcadc527062f77b4f82b189 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 28 Nov 2023 13:44:40 +0100 Subject: [PATCH 815/982] Use shorthand attributes in HVV departures (#104637) * Use shorthand attributes in HVV departures * Apply code review suggestion Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> * Apply code review sugesstion Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../hvv_departures/binary_sensor.py | 50 +++++++------------ .../components/hvv_departures/sensor.py | 30 +++++------ 2 files changed, 30 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 0ec08e9c791..8337921acf6 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -125,13 +125,29 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): _attr_attribution = ATTRIBUTION _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.PROBLEM def __init__(self, coordinator, idx, config_entry): """Initialize.""" super().__init__(coordinator) self.coordinator = coordinator self.idx = idx - self.config_entry = config_entry + + self._attr_name = coordinator.data[idx]["name"] + self._attr_unique_id = idx + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + config_entry.entry_id, + config_entry.data[CONF_STATION]["id"], + config_entry.data[CONF_STATION]["type"], + ) + }, + manufacturer=MANUFACTURER, + name=f"Departures at {config_entry.data[CONF_STATION]['name']}", + ) @property def is_on(self): @@ -146,38 +162,6 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): and self.coordinator.data[self.idx]["available"] ) - @property - def device_info(self): - """Return the device info for this sensor.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={ - ( - DOMAIN, - self.config_entry.entry_id, - self.config_entry.data[CONF_STATION]["id"], - self.config_entry.data[CONF_STATION]["type"], - ) - }, - manufacturer=MANUFACTURER, - name=f"Departures at {self.config_entry.data[CONF_STATION]['name']}", - ) - - @property - def name(self): - """Return the name of the sensor.""" - return self.coordinator.data[self.idx]["name"] - - @property - def unique_id(self): - """Return a unique ID to use for this sensor.""" - return self.idx - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return BinarySensorDeviceClass.PROBLEM - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 76a7966a6ed..a8efb663c90 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -73,6 +73,19 @@ class HVVDepartureSensor(SensorEntity): station_id = config_entry.data[CONF_STATION]["id"] station_type = config_entry.data[CONF_STATION]["type"] self._attr_unique_id = f"{config_entry.entry_id}-{station_id}-{station_type}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + config_entry.entry_id, + config_entry.data[CONF_STATION]["id"], + config_entry.data[CONF_STATION]["type"], + ) + }, + manufacturer=MANUFACTURER, + name=config_entry.data[CONF_STATION]["name"], + ) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self, **kwargs: Any) -> None: @@ -165,20 +178,3 @@ class HVVDepartureSensor(SensorEntity): } ) self._attr_extra_state_attributes[ATTR_NEXT] = departures - - @property - def device_info(self): - """Return the device info for this sensor.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={ - ( - DOMAIN, - self.config_entry.entry_id, - self.config_entry.data[CONF_STATION]["id"], - self.config_entry.data[CONF_STATION]["type"], - ) - }, - manufacturer=MANUFACTURER, - name=self.config_entry.data[CONF_STATION]["name"], - ) From d3b04a5a581a4acf0f009c4f9664ade85428350a Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:56:17 +0100 Subject: [PATCH 816/982] Add Devialet integration (#86551) * Add Devialet * Bump Devialet==1.4.0 * Bump Devialet==1.4.1 * Sort manifest and add shorthand * Black formatting * Fix incompatible type * Add type guarding for name * Rename host keywork in tests * Fix Devialet tests * Add update coordinator * Update devialet tests * Create unique_id from entry data --- CODEOWNERS | 2 + homeassistant/components/devialet/__init__.py | 31 ++ .../components/devialet/config_flow.py | 104 ++++++ homeassistant/components/devialet/const.py | 12 + .../components/devialet/coordinator.py | 32 ++ .../components/devialet/diagnostics.py | 20 ++ .../components/devialet/manifest.json | 12 + .../components/devialet/media_player.py | 210 ++++++++++++ .../components/devialet/strings.json | 22 ++ .../components/devialet/translations/en.json | 22 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/devialet/__init__.py | 150 +++++++++ .../devialet/fixtures/current_position.json | 3 + .../devialet/fixtures/equalizer.json | 26 ++ .../devialet/fixtures/general_info.json | 18 + .../devialet/fixtures/night_mode.json | 3 + .../devialet/fixtures/no_current_source.json | 7 + .../devialet/fixtures/source_state.json | 20 ++ .../components/devialet/fixtures/sources.json | 41 +++ .../devialet/fixtures/system_info.json | 6 + .../components/devialet/fixtures/volume.json | 3 + tests/components/devialet/test_config_flow.py | 154 +++++++++ tests/components/devialet/test_diagnostics.py | 40 +++ tests/components/devialet/test_init.py | 49 +++ .../components/devialet/test_media_player.py | 312 ++++++++++++++++++ 29 files changed, 1317 insertions(+) create mode 100644 homeassistant/components/devialet/__init__.py create mode 100644 homeassistant/components/devialet/config_flow.py create mode 100644 homeassistant/components/devialet/const.py create mode 100644 homeassistant/components/devialet/coordinator.py create mode 100644 homeassistant/components/devialet/diagnostics.py create mode 100644 homeassistant/components/devialet/manifest.json create mode 100644 homeassistant/components/devialet/media_player.py create mode 100644 homeassistant/components/devialet/strings.json create mode 100644 homeassistant/components/devialet/translations/en.json create mode 100644 tests/components/devialet/__init__.py create mode 100644 tests/components/devialet/fixtures/current_position.json create mode 100644 tests/components/devialet/fixtures/equalizer.json create mode 100644 tests/components/devialet/fixtures/general_info.json create mode 100644 tests/components/devialet/fixtures/night_mode.json create mode 100644 tests/components/devialet/fixtures/no_current_source.json create mode 100644 tests/components/devialet/fixtures/source_state.json create mode 100644 tests/components/devialet/fixtures/sources.json create mode 100644 tests/components/devialet/fixtures/system_info.json create mode 100644 tests/components/devialet/fixtures/volume.json create mode 100644 tests/components/devialet/test_config_flow.py create mode 100644 tests/components/devialet/test_diagnostics.py create mode 100644 tests/components/devialet/test_init.py create mode 100644 tests/components/devialet/test_media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 60071eeeb61..ec32f941d56 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -259,6 +259,8 @@ build.json @home-assistant/supervisor /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney /tests/components/derivative/ @afaucogney +/homeassistant/components/devialet/ @fwestenberg +/tests/components/devialet/ @fwestenberg /homeassistant/components/device_automation/ @home-assistant/core /tests/components/device_automation/ @home-assistant/core /homeassistant/components/device_tracker/ @home-assistant/core diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py new file mode 100644 index 00000000000..034f93abb68 --- /dev/null +++ b/homeassistant/components/devialet/__init__.py @@ -0,0 +1,31 @@ +"""The Devialet integration.""" +from __future__ import annotations + +from devialet import DevialetApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Devialet from a config entry.""" + session = async_get_clientsession(hass) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DevialetApi( + entry.data[CONF_HOST], session + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Devialet config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py new file mode 100644 index 00000000000..de52788de50 --- /dev/null +++ b/homeassistant/components/devialet/config_flow.py @@ -0,0 +1,104 @@ +"""Support for Devialet Phantom speakers.""" +from __future__ import annotations + +import logging +from typing import Any + +from devialet.devialet_api import DevialetApi +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +LOGGER = logging.getLogger(__package__) + + +class DevialetFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Devialet.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self._host: str | None = None + self._name: str | None = None + self._model: str | None = None + self._serial: str | None = None + self._errors: dict[str, str] = {} + + async def async_validate_input(self) -> FlowResult | None: + """Validate the input using the Devialet API.""" + + self._errors.clear() + session = async_get_clientsession(self.hass) + client = DevialetApi(self._host, session) + + if not await client.async_update() or client.serial is None: + self._errors["base"] = "cannot_connect" + LOGGER.error("Cannot connect") + return None + + await self.async_set_unique_id(client.serial) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=client.device_name, + data={CONF_HOST: self._host, CONF_NAME: client.device_name}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user or zeroconf.""" + + if user_input is not None: + self._host = user_input[CONF_HOST] + result = await self.async_validate_input() + if result is not None: + return result + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=self._errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle a flow initialized by zeroconf discovery.""" + LOGGER.info("Devialet device found via ZEROCONF: %s", discovery_info) + + self._host = discovery_info.host + self._name = discovery_info.name.split(".", 1)[0] + self._model = discovery_info.properties["model"] + self._serial = discovery_info.properties["serialNumber"] + + await self.async_set_unique_id(self._serial) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = {"title": self._name} + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-confirmation of discovered node.""" + title = f"{self._name} ({self._model})" + + if user_input is not None: + result = await self.async_validate_input() + if result is not None: + return result + + return self.async_show_form( + step_id="confirm", + description_placeholders={"device": self._model, "title": title}, + errors=self._errors, + last_step=True, + ) diff --git a/homeassistant/components/devialet/const.py b/homeassistant/components/devialet/const.py new file mode 100644 index 00000000000..ccb4fbc7964 --- /dev/null +++ b/homeassistant/components/devialet/const.py @@ -0,0 +1,12 @@ +"""Constants for the Devialet integration.""" +from typing import Final + +DOMAIN: Final = "devialet" +MANUFACTURER: Final = "Devialet" + +SOUND_MODES = { + "Custom": "custom", + "Flat": "flat", + "Night mode": "night mode", + "Voice": "voice", +} diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py new file mode 100644 index 00000000000..f0ee47150cc --- /dev/null +++ b/homeassistant/components/devialet/coordinator.py @@ -0,0 +1,32 @@ +"""Class representing a Devialet update coordinator.""" +from datetime import timedelta +import logging + +from devialet import DevialetApi + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + + +class DevialetCoordinator(DataUpdateCoordinator): + """Devialet update coordinator.""" + + def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + await self.client.async_update() diff --git a/homeassistant/components/devialet/diagnostics.py b/homeassistant/components/devialet/diagnostics.py new file mode 100644 index 00000000000..f9824a9cad1 --- /dev/null +++ b/homeassistant/components/devialet/diagnostics.py @@ -0,0 +1,20 @@ +"""Diagnostics support for Devialet.""" +from __future__ import annotations + +from typing import Any + +from devialet import DevialetApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client: DevialetApi = hass.data[DOMAIN][entry.entry_id] + + return await client.async_get_diagnostics() diff --git a/homeassistant/components/devialet/manifest.json b/homeassistant/components/devialet/manifest.json new file mode 100644 index 00000000000..286b9bfb112 --- /dev/null +++ b/homeassistant/components/devialet/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "devialet", + "name": "Devialet", + "after_dependencies": ["zeroconf"], + "codeowners": ["@fwestenberg"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/devialet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["devialet==1.4.3"], + "zeroconf": ["_devialet-http._tcp.local."] +} diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py new file mode 100644 index 00000000000..75fc420fa87 --- /dev/null +++ b/homeassistant/components/devialet/media_player.py @@ -0,0 +1,210 @@ +"""Support for Devialet speakers.""" +from __future__ import annotations + +from devialet.const import NORMAL_INPUTS + +from homeassistant.components.media_player import ( + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, SOUND_MODES +from .coordinator import DevialetCoordinator + +SUPPORT_DEVIALET = ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE +) + +DEVIALET_TO_HA_FEATURE_MAP = { + "play": MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP, + "pause": MediaPlayerEntityFeature.PAUSE, + "previous": MediaPlayerEntityFeature.PREVIOUS_TRACK, + "next": MediaPlayerEntityFeature.NEXT_TRACK, + "seek": MediaPlayerEntityFeature.SEEK, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Devialet entry.""" + client = hass.data[DOMAIN][entry.entry_id] + coordinator = DevialetCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + + async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) + + +class DevialetMediaPlayerEntity(CoordinatorEntity, MediaPlayerEntity): + """Devialet media player.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, coordinator, entry: ConfigEntry) -> None: + """Initialize the Devialet device.""" + self.coordinator = coordinator + super().__init__(coordinator) + + self._attr_unique_id = str(entry.unique_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + model=self.coordinator.client.model, + name=entry.data[CONF_NAME], + sw_version=self.coordinator.client.version, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.client.is_available: + self.async_write_ha_state() + return + + self._attr_volume_level = self.coordinator.client.volume_level + self._attr_is_volume_muted = self.coordinator.client.is_volume_muted + self._attr_source_list = self.coordinator.client.source_list + self._attr_sound_mode_list = sorted(SOUND_MODES) + self._attr_media_artist = self.coordinator.client.media_artist + self._attr_media_album_name = self.coordinator.client.media_album_name + self._attr_media_artist = self.coordinator.client.media_artist + self._attr_media_image_url = self.coordinator.client.media_image_url + self._attr_media_duration = self.coordinator.client.media_duration + self._attr_media_position = self.coordinator.client.current_position + self._attr_media_position_updated_at = ( + self.coordinator.client.position_updated_at + ) + self._attr_media_title = ( + self.coordinator.client.media_title + if self.coordinator.client.media_title + else self.source + ) + self.async_write_ha_state() + + @property + def state(self) -> MediaPlayerState | None: + """Return the state of the device.""" + playing_state = self.coordinator.client.playing_state + + if not playing_state: + return MediaPlayerState.IDLE + if playing_state == "playing": + return MediaPlayerState.PLAYING + if playing_state == "paused": + return MediaPlayerState.PAUSED + return MediaPlayerState.ON + + @property + def available(self) -> bool: + """Return if the media player is available.""" + return self.coordinator.client.is_available + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Flag media player features that are supported.""" + features = SUPPORT_DEVIALET + + if self.coordinator.client.source_state is None: + return features + + if not self.coordinator.client.available_options: + return features + + for option in self.coordinator.client.available_options: + features |= DEVIALET_TO_HA_FEATURE_MAP.get(option, 0) + return features + + @property + def source(self) -> str | None: + """Return the current input source.""" + source = self.coordinator.client.source + + for pretty_name, name in NORMAL_INPUTS.items(): + if source == name: + return pretty_name + return None + + @property + def sound_mode(self) -> str | None: + """Return the current sound mode.""" + if self.coordinator.client.equalizer is not None: + sound_mode = self.coordinator.client.equalizer + elif self.coordinator.client.night_mode: + sound_mode = "night mode" + else: + return None + + for pretty_name, mode in SOUND_MODES.items(): + if sound_mode == mode: + return pretty_name + return None + + async def async_volume_up(self) -> None: + """Volume up media player.""" + await self.coordinator.client.async_volume_up() + + async def async_volume_down(self) -> None: + """Volume down media player.""" + await self.coordinator.client.async_volume_down() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self.coordinator.client.async_set_volume_level(volume) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute (true) or unmute (false) media player.""" + await self.coordinator.client.async_mute_volume(mute) + + async def async_media_play(self) -> None: + """Play media player.""" + await self.coordinator.client.async_media_play() + + async def async_media_pause(self) -> None: + """Pause media player.""" + await self.coordinator.client.async_media_pause() + + async def async_media_stop(self) -> None: + """Pause media player.""" + await self.coordinator.client.async_media_stop() + + async def async_media_next_track(self) -> None: + """Send the next track command.""" + await self.coordinator.client.async_media_next_track() + + async def async_media_previous_track(self) -> None: + """Send the previous track command.""" + await self.coordinator.client.async_media_previous_track() + + async def async_media_seek(self, position: float) -> None: + """Send seek command.""" + await self.coordinator.client.async_media_seek(position) + + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Send sound mode command.""" + for pretty_name, mode in SOUND_MODES.items(): + if sound_mode == pretty_name: + if mode == "night mode": + await self.coordinator.client.async_set_night_mode(True) + else: + await self.coordinator.client.async_set_night_mode(False) + await self.coordinator.client.async_set_equalizer(mode) + + async def async_turn_off(self) -> None: + """Turn off media player.""" + await self.coordinator.client.async_turn_off() + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + await self.coordinator.client.async_select_source(source) diff --git a/homeassistant/components/devialet/strings.json b/homeassistant/components/devialet/strings.json new file mode 100644 index 00000000000..0a90da49bf4 --- /dev/null +++ b/homeassistant/components/devialet/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "{title}", + "step": { + "user": { + "description": "Please enter the host name or IP address of the Devialet device.", + "data": { + "host": "Host" + } + }, + "confirm": { + "description": "Do you want to set up Devialet device {device}?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/devialet/translations/en.json b/homeassistant/components/devialet/translations/en.json new file mode 100644 index 00000000000..af0cfc4c122 --- /dev/null +++ b/homeassistant/components/devialet/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{title}", + "step": { + "confirm": { + "description": "Do you want to set up Devialet device {device}?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Please enter the host name or IP address of the Devialet device." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fbd0b40551b..57503f0ef32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -95,6 +95,7 @@ FLOWS = { "deconz", "deluge", "denonavr", + "devialet", "devolo_home_control", "devolo_home_network", "dexcom", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 312a2838051..f0af72624f6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1067,6 +1067,12 @@ } } }, + "devialet": { + "name": "Devialet", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "device_sun_light_trigger": { "name": "Presence-based Lights", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 06daf8bc4a8..e8d117d1f33 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -364,6 +364,11 @@ ZEROCONF = { "domain": "forked_daapd", }, ], + "_devialet-http._tcp.local.": [ + { + "domain": "devialet", + }, + ], "_dkapi._tcp.local.": [ { "domain": "daikin", diff --git a/requirements_all.txt b/requirements_all.txt index d2505e5726a..cc20abb8af8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -683,6 +683,9 @@ demetriek==0.4.0 # homeassistant.components.denonavr denonavr==0.11.4 +# homeassistant.components.devialet +devialet==1.4.3 + # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2098110ca7b..2a7a8c22e3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -558,6 +558,9 @@ demetriek==0.4.0 # homeassistant.components.denonavr denonavr==0.11.4 +# homeassistant.components.devialet +devialet==1.4.3 + # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/tests/components/devialet/__init__.py b/tests/components/devialet/__init__.py new file mode 100644 index 00000000000..28ab6229c44 --- /dev/null +++ b/tests/components/devialet/__init__.py @@ -0,0 +1,150 @@ +"""Tests for the Devialet integration.""" + +from ipaddress import ip_address + +from aiohttp import ClientError as ServerTimeoutError +from devialet.const import UrlSuffix + +from homeassistant.components import zeroconf +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +NAME = "Livingroom" +SERIAL = "L00P00000AB11" +HOST = "127.0.0.1" +CONF_INPUT = {CONF_HOST: HOST} + +CONF_DATA = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + +MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} +MOCK_USER_INPUT = {CONF_HOST: HOST} +MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(HOST), + ip_addresses=[ip_address(HOST)], + hostname="PhantomISilver-L00P00000AB11.local.", + type="_devialet-http._tcp.", + name="Livingroom", + port=80, + properties={ + "_raw": { + "firmwareFamily": "DOS", + "firmwareVersion": "2.16.1.49152", + "ipControlVersion": "1", + "manufacturer": "Devialet", + "model": "Phantom I Silver", + "path": "/ipcontrol/v1", + "serialNumber": "L00P00000AB11", + }, + "firmwareFamily": "DOS", + "firmwareVersion": "2.16.1.49152", + "ipControlVersion": "1", + "manufacturer": "Devialet", + "model": "Phantom I Silver", + "path": "/ipcontrol/v1", + "serialNumber": "L00P00000AB11", + }, +) + + +def mock_unavailable(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=ServerTimeoutError + ) + + +def mock_idle(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", + text=load_fixture("general_info.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_SOURCE}", + exc=ServerTimeoutError, + ) + + +def mock_playing(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", + text=load_fixture("general_info.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_SOURCE}", + text=load_fixture("source_state.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_SOURCES}", + text=load_fixture("sources.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_VOLUME}", + text=load_fixture("volume.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_NIGHT_MODE}", + text=load_fixture("night_mode.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_EQUALIZER}", + text=load_fixture("equalizer.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_POSITION}", + text=load_fixture("current_position.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +async def setup_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + skip_entry_setup: bool = False, + state: str = "playing", + serial: str = SERIAL, +) -> MockConfigEntry: + """Set up the Devialet integration in Home Assistant.""" + + if state == "playing": + mock_playing(aioclient_mock) + elif state == "unavailable": + mock_unavailable(aioclient_mock) + elif state == "idle": + mock_idle(aioclient_mock) + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial, + data=CONF_DATA, + ) + + entry.add_to_hass(hass) + + if not skip_entry_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/devialet/fixtures/current_position.json b/tests/components/devialet/fixtures/current_position.json new file mode 100644 index 00000000000..2b9761cc03a --- /dev/null +++ b/tests/components/devialet/fixtures/current_position.json @@ -0,0 +1,3 @@ +{ + "position": 123102 +} diff --git a/tests/components/devialet/fixtures/equalizer.json b/tests/components/devialet/fixtures/equalizer.json new file mode 100644 index 00000000000..be9ea651d6e --- /dev/null +++ b/tests/components/devialet/fixtures/equalizer.json @@ -0,0 +1,26 @@ +{ + "availablePresets": ["custom", "flat", "voice"], + "currentEqualization": { + "high": { + "gain": 0 + }, + "low": { + "gain": 0 + } + }, + "customEqualization": { + "high": { + "gain": 0 + }, + "low": { + "gain": 0 + } + }, + "enabled": true, + "gainRange": { + "max": 6, + "min": -6, + "stepPrecision": 1 + }, + "preset": "flat" +} diff --git a/tests/components/devialet/fixtures/general_info.json b/tests/components/devialet/fixtures/general_info.json new file mode 100644 index 00000000000..6ff1a724f08 --- /dev/null +++ b/tests/components/devialet/fixtures/general_info.json @@ -0,0 +1,18 @@ +{ + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "deviceName": "Livingroom", + "firmwareFamily": "DOS", + "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "ipControlVersion": "1", + "model": "Phantom I Silver", + "release": { + "buildType": "release", + "canonicalVersion": "2.16.1.49152", + "version": "2.16.1" + }, + "role": "FrontLeft", + "serial": "L00P00000AB11", + "standbyEntryDelay": 0, + "standbyState": "Unknown", + "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl" +} diff --git a/tests/components/devialet/fixtures/night_mode.json b/tests/components/devialet/fixtures/night_mode.json new file mode 100644 index 00000000000..e61cc12151d --- /dev/null +++ b/tests/components/devialet/fixtures/night_mode.json @@ -0,0 +1,3 @@ +{ + "nightMode": "off" +} diff --git a/tests/components/devialet/fixtures/no_current_source.json b/tests/components/devialet/fixtures/no_current_source.json new file mode 100644 index 00000000000..ac16468597d --- /dev/null +++ b/tests/components/devialet/fixtures/no_current_source.json @@ -0,0 +1,7 @@ +{ + "error": { + "code": "NoCurrentSource", + "details": {}, + "message": "" + } +} diff --git a/tests/components/devialet/fixtures/source_state.json b/tests/components/devialet/fixtures/source_state.json new file mode 100644 index 00000000000..d389675ac98 --- /dev/null +++ b/tests/components/devialet/fixtures/source_state.json @@ -0,0 +1,20 @@ +{ + "availableOptions": ["play", "pause", "previous", "next", "seek"], + "metadata": { + "album": "1 (Remastered)", + "artist": "The Beatles", + "coverArtDataPresent": false, + "coverArtUrl": "https://i.scdn.co/image/ab67616d0000b273582d56ce20fe0146ffa0e5cf", + "duration": 425653, + "mediaType": "unknown", + "title": "Hey Jude - Remastered 2015" + }, + "muteState": "unmuted", + "peerDeviceName": "", + "playingState": "playing", + "source": { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "7b0d8ed0-5650-45cd-841b-647b78730bfb", + "type": "spotifyconnect" + } +} diff --git a/tests/components/devialet/fixtures/sources.json b/tests/components/devialet/fixtures/sources.json new file mode 100644 index 00000000000..5f484314d73 --- /dev/null +++ b/tests/components/devialet/fixtures/sources.json @@ -0,0 +1,41 @@ +{ + "sources": [ + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "7b0d8ed0-5650-45cd-841b-647b78730bfb", + "type": "spotifyconnect" + }, + { + "deviceId": "9abc87d6-ef54-321d-0g9h-ijk876l54m32", + "sourceId": "12708064-01fa-4e25-a0f1-f94b3de49baa", + "streamLockAvailable": false, + "type": "optical" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "82834351-8255-4e2e-9ce2-b7d4da0aa3b0", + "streamLockAvailable": false, + "type": "optical" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "07b1bf6d-9216-4a7b-8d53-5590cee21d90", + "type": "upnp" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "1015e17d-d515-419d-a47b-4a7252bff838", + "type": "airplay2" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "88186c24-f896-4ef0-a731-a6c8f8f01908", + "type": "bluetooth" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "acfd9fe6-7e29-4c2b-b2bd-5083486a5291", + "type": "raat" + } + ] +} diff --git a/tests/components/devialet/fixtures/system_info.json b/tests/components/devialet/fixtures/system_info.json new file mode 100644 index 00000000000..f496e5557d2 --- /dev/null +++ b/tests/components/devialet/fixtures/system_info.json @@ -0,0 +1,6 @@ +{ + "availableFeatures": ["nightMode", "equalizer", "balance"], + "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl", + "systemName": "Devialet" +} diff --git a/tests/components/devialet/fixtures/volume.json b/tests/components/devialet/fixtures/volume.json new file mode 100644 index 00000000000..365d5ed776d --- /dev/null +++ b/tests/components/devialet/fixtures/volume.json @@ -0,0 +1,3 @@ +{ + "volume": 20 +} diff --git a/tests/components/devialet/test_config_flow.py b/tests/components/devialet/test_config_flow.py new file mode 100644 index 00000000000..0bacc558b74 --- /dev/null +++ b/tests/components/devialet/test_config_flow.py @@ -0,0 +1,154 @@ +"""Test the Devialet config flow.""" +from unittest.mock import patch + +from aiohttp import ClientError as HTTPClientError +from devialet.const import UrlSuffix + +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + HOST, + MOCK_USER_INPUT, + MOCK_ZEROCONF_DATA, + NAME, + mock_playing, + setup_integration, +) + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + +async def test_cannot_connect( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we show user form on connection error.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=HTTPClientError + ) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort user flow if DirecTV receiver already configured.""" + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the full manual user flow from start to finish.""" + mock_playing(aioclient_mock) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = MOCK_USER_INPUT.copy() + with patch( + "homeassistant.components.devialet.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + + +async def test_zeroconf_devialet( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we pass Devialet devices to the discovery manager.""" + mock_playing(aioclient_mock) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + + assert result["type"] == "form" + + with patch( + "homeassistant.components.devialet.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Livingroom" + assert result2["data"] == { + CONF_HOST: HOST, + CONF_NAME: NAME, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_async_step_confirm( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test starting a flow from discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=HTTPClientError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT.copy() + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/devialet/test_diagnostics.py b/tests/components/devialet/test_diagnostics.py new file mode 100644 index 00000000000..82600de7cf5 --- /dev/null +++ b/tests/components/devialet/test_diagnostics.py @@ -0,0 +1,40 @@ +"""Test the Devialet diagnostics.""" +import json + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test diagnostics.""" + entry = await setup_integration(hass, aioclient_mock) + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { + "is_available": True, + "general_info": json.loads(load_fixture("general_info.json", "devialet")), + "sources": json.loads(load_fixture("sources.json", "devialet")), + "source_state": json.loads(load_fixture("source_state.json", "devialet")), + "volume": json.loads(load_fixture("volume.json", "devialet")), + "night_mode": json.loads(load_fixture("night_mode.json", "devialet")), + "equalizer": json.loads(load_fixture("equalizer.json", "devialet")), + "source_list": [ + "Airplay", + "Bluetooth", + "Online", + "Optical left", + "Optical right", + "Raat", + "Spotify Connect", + ], + "source": "spotifyconnect", + } diff --git a/tests/components/devialet/test_init.py b/tests/components/devialet/test_init.py new file mode 100644 index 00000000000..86d383e91d8 --- /dev/null +++ b/tests/components/devialet/test_init.py @@ -0,0 +1,49 @@ +"""Test the Devialet init.""" +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerState +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import NAME, setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_load_unload_config_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is not None + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == MediaPlayerState.PLAYING + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_load_unload_config_entry_when_device_unavailable( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading when the device is unavailable.""" + entry = await setup_integration(hass, aioclient_mock, state="unavailable") + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is not None + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == "unavailable" + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py new file mode 100644 index 00000000000..56381bf6de4 --- /dev/null +++ b/tests/components/devialet/test_media_player.py @@ -0,0 +1,312 @@ +"""Test the Devialet init.""" +from unittest.mock import PropertyMock, patch + +from devialet import DevialetApi +from devialet.const import UrlSuffix +from yarl import URL + +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.components.devialet.media_player import SUPPORT_DEVIALET +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, + DOMAIN as MP_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + ATTR_SUPPORTED_FEATURES, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import HOST, NAME, setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + +SERVICE_TO_URL = { + SERVICE_MEDIA_SEEK: [UrlSuffix.SEEK], + SERVICE_MEDIA_PLAY: [UrlSuffix.PLAY], + SERVICE_MEDIA_PAUSE: [UrlSuffix.PAUSE], + SERVICE_MEDIA_STOP: [UrlSuffix.PAUSE], + SERVICE_MEDIA_PREVIOUS_TRACK: [UrlSuffix.PREVIOUS_TRACK], + SERVICE_MEDIA_NEXT_TRACK: [UrlSuffix.NEXT_TRACK], + SERVICE_TURN_OFF: [UrlSuffix.TURN_OFF], + SERVICE_VOLUME_UP: [UrlSuffix.VOLUME_UP], + SERVICE_VOLUME_DOWN: [UrlSuffix.VOLUME_DOWN], + SERVICE_VOLUME_SET: [UrlSuffix.VOLUME_SET], + SERVICE_VOLUME_MUTE: [UrlSuffix.MUTE, UrlSuffix.UNMUTE], + SERVICE_SELECT_SOUND_MODE: [UrlSuffix.EQUALIZER, UrlSuffix.NIGHT_MODE], + SERVICE_SELECT_SOURCE: [ + str(UrlSuffix.SELECT_SOURCE).replace( + "%SOURCE_ID%", "82834351-8255-4e2e-9ce2-b7d4da0aa3b0" + ), + str(UrlSuffix.SELECT_SOURCE).replace( + "%SOURCE_ID%", "07b1bf6d-9216-4a7b-8d53-5590cee21d90" + ), + ], +} + +SERVICE_TO_DATA = { + SERVICE_MEDIA_SEEK: [{"seek_position": 321}], + SERVICE_MEDIA_PLAY: [{}], + SERVICE_MEDIA_PAUSE: [{}], + SERVICE_MEDIA_STOP: [{}], + SERVICE_MEDIA_PREVIOUS_TRACK: [{}], + SERVICE_MEDIA_NEXT_TRACK: [{}], + SERVICE_TURN_OFF: [{}], + SERVICE_VOLUME_UP: [{}], + SERVICE_VOLUME_DOWN: [{}], + SERVICE_VOLUME_SET: [{ATTR_MEDIA_VOLUME_LEVEL: 0.5}], + SERVICE_VOLUME_MUTE: [ + {ATTR_MEDIA_VOLUME_MUTED: True}, + {ATTR_MEDIA_VOLUME_MUTED: False}, + ], + SERVICE_SELECT_SOUND_MODE: [ + {ATTR_SOUND_MODE: "Night mode"}, + {ATTR_SOUND_MODE: "Flat"}, + ], + SERVICE_SELECT_SOURCE: [ + {ATTR_INPUT_SOURCE: "Optical left"}, + {ATTR_INPUT_SOURCE: "Online"}, + ], +} + + +async def test_media_player_playing( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + await async_setup_component(hass, "homeassistant", {}) + entry = await setup_integration(hass, aioclient_mock) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: [f"{MP_DOMAIN}.{NAME.lower()}"]}, + blocking=True, + ) + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == MediaPlayerState.PLAYING + assert state.name == NAME + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2 + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False + assert state.attributes[ATTR_INPUT_SOURCE_LIST] is not None + assert state.attributes[ATTR_SOUND_MODE_LIST] is not None + assert state.attributes[ATTR_MEDIA_ARTIST] == "The Beatles" + assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "1 (Remastered)" + assert state.attributes[ATTR_MEDIA_TITLE] == "Hey Jude - Remastered 2015" + assert state.attributes[ATTR_ENTITY_PICTURE] is not None + assert state.attributes[ATTR_MEDIA_DURATION] == 425653 + assert state.attributes[ATTR_MEDIA_POSITION] == 123102 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] is not None + assert state.attributes[ATTR_SUPPORTED_FEATURES] is not None + assert state.attributes[ATTR_INPUT_SOURCE] is not None + assert state.attributes[ATTR_SOUND_MODE] is not None + + with patch( + "homeassistant.components.devialet.DevialetApi.playing_state", + new_callable=PropertyMock, + ) as mock: + mock.return_value = MediaPlayerState.PAUSED + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").state + == MediaPlayerState.PAUSED + ) + + with patch( + "homeassistant.components.devialet.DevialetApi.playing_state", + new_callable=PropertyMock, + ) as mock: + mock.return_value = MediaPlayerState.ON + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").state == MediaPlayerState.ON + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = None + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = True + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes[ + ATTR_SOUND_MODE + ] + == "Night mode" + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = "unexpected_value" + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = False + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_SOUND_MODE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = None + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = None + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_SOUND_MODE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + with patch.object( + DevialetApi, "available_options", new_callable=PropertyMock + ) as mock: + mock.return_value = None + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes[ + ATTR_SUPPORTED_FEATURES + ] + == SUPPORT_DEVIALET + ) + + with patch.object(DevialetApi, "source", new_callable=PropertyMock) as mock: + mock.return_value = "someSource" + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_INPUT_SOURCE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_offline( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock, state=STATE_UNAVAILABLE) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == STATE_UNAVAILABLE + assert state.name == NAME + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_without_serial( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock, serial=None) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is None + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_services( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet services.""" + entry = await setup_integration( + hass, aioclient_mock, state=MediaPlayerState.PLAYING + ) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + target = {ATTR_ENTITY_ID: hass.states.get(f"{MP_DOMAIN}.{NAME}").entity_id} + + for i, (service, urls) in enumerate(SERVICE_TO_URL.items()): + for url in urls: + aioclient_mock.post(f"http://{HOST}{url}") + + for data_set in list(SERVICE_TO_DATA.values())[i]: + service_data = target.copy() + service_data.update(data_set) + + await hass.services.async_call( + MP_DOMAIN, + service, + service_data=service_data, + blocking=True, + ) + await hass.async_block_till_done() + + for url in urls: + call_available = False + for item in aioclient_mock.mock_calls: + if item[0] == "POST" and item[1] == URL(f"http://{HOST}{url}"): + call_available = True + break + + assert call_available + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED From 7533895a3d72dfddbe36838d7322b5d89246e9ff Mon Sep 17 00:00:00 2001 From: glanch <49610590+glanch@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:58:40 +0100 Subject: [PATCH 817/982] Add tag name to `tag_scanned` event data (#97553) * Add tag name to tag scanned event data * Make name in event data optional, add test cases for events * Simplify sanity None check of tag data Co-authored-by: Robert Resch * Apply suggestions from code review --------- Co-authored-by: Robert Resch Co-authored-by: Erik Montnemery --- homeassistant/components/tag/__init__.py | 15 +++- tests/components/tag/test_event.py | 106 +++++++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 tests/components/tag/test_event.py diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index e82083f73ec..59b0fa995e4 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -118,10 +118,19 @@ async def async_scan_tag( if DOMAIN not in hass.config.components: raise HomeAssistantError("tag component has not been set up.") - hass.bus.async_fire( - EVENT_TAG_SCANNED, {TAG_ID: tag_id, DEVICE_ID: device_id}, context=context - ) helper = hass.data[DOMAIN][TAGS] + + # Get name from helper, default value None if not present in data + tag_name = None + if tag_data := helper.data.get(tag_id): + tag_name = tag_data.get(CONF_NAME) + + hass.bus.async_fire( + EVENT_TAG_SCANNED, + {TAG_ID: tag_id, CONF_NAME: tag_name, DEVICE_ID: device_id}, + context=context, + ) + if tag_id in helper.data: await helper.async_update_item(tag_id, {LAST_SCANNED: dt_util.utcnow()}) else: diff --git a/tests/components/tag/test_event.py b/tests/components/tag/test_event.py new file mode 100644 index 00000000000..7112a0cda4f --- /dev/null +++ b/tests/components/tag/test_event.py @@ -0,0 +1,106 @@ +"""Tests for the tag component.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.tag import DOMAIN, EVENT_TAG_SCANNED, async_scan_tag +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import async_capture_events +from tests.typing import WebSocketGenerator + +TEST_TAG_ID = "test tag id" +TEST_TAG_NAME = "test tag name" +TEST_DEVICE_ID = "device id" + + +@pytest.fixture +def storage_setup_named_tag( + hass, + hass_storage, +): + """Storage setup for test case of named tags.""" + + async def _storage(items=None): + if items is None: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": {"items": [{"id": TEST_TAG_ID, CONF_NAME: TEST_TAG_NAME}]}, + } + else: + hass_storage[DOMAIN] = items + config = {DOMAIN: {}} + return await async_setup_component(hass, DOMAIN, config) + + return _storage + + +async def test_named_tag_scanned_event( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup_named_tag +) -> None: + """Test scanning named tag triggering event.""" + assert await storage_setup_named_tag() + + await hass_ws_client(hass) + + events = async_capture_events(hass, EVENT_TAG_SCANNED) + + now = dt_util.utcnow() + with patch("homeassistant.util.dt.utcnow", return_value=now): + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + + assert len(events) == 1 + + event = events[0] + event_data = event.data + + assert event_data["name"] == TEST_TAG_NAME + assert event_data["device_id"] == TEST_DEVICE_ID + assert event_data["tag_id"] == TEST_TAG_ID + + +@pytest.fixture +def storage_setup_unnamed_tag(hass, hass_storage): + """Storage setup for test case of unnamed tags.""" + + async def _storage(items=None): + if items is None: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": {"items": [{"id": TEST_TAG_ID}]}, + } + else: + hass_storage[DOMAIN] = items + config = {DOMAIN: {}} + return await async_setup_component(hass, DOMAIN, config) + + return _storage + + +async def test_unnamed_tag_scanned_event( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup_unnamed_tag +) -> None: + """Test scanning named tag triggering event.""" + assert await storage_setup_unnamed_tag() + + await hass_ws_client(hass) + + events = async_capture_events(hass, EVENT_TAG_SCANNED) + + now = dt_util.utcnow() + with patch("homeassistant.util.dt.utcnow", return_value=now): + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + + assert len(events) == 1 + + event = events[0] + event_data = event.data + + assert event_data["name"] is None + assert event_data["device_id"] == TEST_DEVICE_ID + assert event_data["tag_id"] == TEST_TAG_ID From 56f2f17ed168b355a5a97270832d414c279e3dd0 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Nov 2023 09:51:47 -0500 Subject: [PATCH 818/982] Bump aiosomecomfort to 0.0.23 (#104641) --- homeassistant/components/honeywell/climate.py | 3 ++- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 2c1c70d01eb..4ecfb6a3b21 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -8,6 +8,7 @@ from typing import Any from aiohttp import ClientConnectionError from aiosomecomfort import ( AuthError, + ConnectionError as asc_ConnectionError, SomeComfortError, UnauthorizedError, UnexpectedResponse, @@ -522,7 +523,7 @@ class HoneywellUSThermostat(ClimateEntity): await _login() return - except (ClientConnectionError, asyncio.TimeoutError): + except (asc_ConnectionError, ClientConnectionError, asyncio.TimeoutError): self._retry += 1 self._attr_available = self._retry <= RETRY return diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 47213476ad9..5db07035988 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.22"] + "requirements": ["AIOSomecomfort==0.0.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc20abb8af8..913cc8353fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.6 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.22 +AIOSomecomfort==0.0.23 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a7a8c22e3c..542f175b689 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.6 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.22 +AIOSomecomfort==0.0.23 # homeassistant.components.adax Adax-local==0.1.5 From 9bdf82eb325a73713ebeb7f0b6e69538683e5a5d Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Tue, 28 Nov 2023 19:32:11 +0100 Subject: [PATCH 819/982] Add info what to enter into host field (#104658) * Add info what to enter into host field * Fix style --- homeassistant/components/adguard/strings.json | 3 +++ homeassistant/components/airtouch4/strings.json | 3 +++ homeassistant/components/airvisual_pro/strings.json | 3 +++ homeassistant/components/alarmdecoder/strings.json | 4 ++++ 4 files changed, 13 insertions(+) diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index e34a7c88229..5b6a5a546f7 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -10,6 +10,9 @@ "username": "[%key:common::config_flow::data::username%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your AdGuard Home." } }, "hassio_confirm": { diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json index 240b3e0007c..c810b991b8e 100644 --- a/homeassistant/components/airtouch4/strings.json +++ b/homeassistant/components/airtouch4/strings.json @@ -12,6 +12,9 @@ "title": "Set up your AirTouch 4 connection details.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your AirTouch controller." } } } diff --git a/homeassistant/components/airvisual_pro/strings.json b/homeassistant/components/airvisual_pro/strings.json index b5c68371fdf..6f690c9eb4a 100644 --- a/homeassistant/components/airvisual_pro/strings.json +++ b/homeassistant/components/airvisual_pro/strings.json @@ -12,6 +12,9 @@ "data": { "ip_address": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "ip_address": "The hostname or IP address of the device running your AirVisual Pro." } } }, diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index d7ac882bb82..e1b5a774a87 100644 --- a/homeassistant/components/alarmdecoder/strings.json +++ b/homeassistant/components/alarmdecoder/strings.json @@ -14,6 +14,10 @@ "port": "[%key:common::config_flow::data::port%]", "device_baudrate": "Device Baud Rate", "device_path": "Device Path" + }, + "data_description": { + "host": "The hostname or IP address of the machine connected to the AlarmDecoder device.", + "port": "The port on which AlarmDecoder is accessible (for example, 10000)" } } }, From 595663778cfe971ba4f15a3da584cc3d401a1531 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Nov 2023 13:34:30 -0500 Subject: [PATCH 820/982] Bump aiosomecomfort to 0.0.24 (#104649) * Bump aiosomecomfort to 0.0.24 * PascalCase change --- homeassistant/components/honeywell/climate.py | 4 ++-- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 4ecfb6a3b21..dfac69b3aed 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -8,7 +8,7 @@ from typing import Any from aiohttp import ClientConnectionError from aiosomecomfort import ( AuthError, - ConnectionError as asc_ConnectionError, + ConnectionError as AscConnectionError, SomeComfortError, UnauthorizedError, UnexpectedResponse, @@ -523,7 +523,7 @@ class HoneywellUSThermostat(ClimateEntity): await _login() return - except (asc_ConnectionError, ClientConnectionError, asyncio.TimeoutError): + except (AscConnectionError, ClientConnectionError, asyncio.TimeoutError): self._retry += 1 self._attr_available = self._retry <= RETRY return diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 5db07035988..c4ddba49357 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.23"] + "requirements": ["AIOSomecomfort==0.0.24"] } diff --git a/requirements_all.txt b/requirements_all.txt index 913cc8353fd..a46d091eaf6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.6 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.23 +AIOSomecomfort==0.0.24 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 542f175b689..9577e2cdced 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.6 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.23 +AIOSomecomfort==0.0.24 # homeassistant.components.adax Adax-local==0.1.5 From 63ef9efa26f97c31b0915753e0f1fd83abdf49a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 28 Nov 2023 21:22:48 +0100 Subject: [PATCH 821/982] Bump pyAdax to 0.4.0 (#104660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updata Adax lib Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/adax/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index 65cffc509d5..2742180333b 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/adax", "iot_class": "local_polling", "loggers": ["adax", "adax_local"], - "requirements": ["adax==0.3.0", "Adax-local==0.1.5"] + "requirements": ["adax==0.4.0", "Adax-local==0.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index a46d091eaf6..7241eeb375d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -146,7 +146,7 @@ WSDiscovery==2.0.0 accuweather==2.1.1 # homeassistant.components.adax -adax==0.3.0 +adax==0.4.0 # homeassistant.components.androidtv adb-shell[async]==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9577e2cdced..310cd192648 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -125,7 +125,7 @@ WSDiscovery==2.0.0 accuweather==2.1.1 # homeassistant.components.adax -adax==0.3.0 +adax==0.4.0 # homeassistant.components.androidtv adb-shell[async]==0.4.4 From 93aa31c8359854759dd26e5a22e2660fe68e4c45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Nov 2023 14:51:35 -0600 Subject: [PATCH 822/982] Bump aioesphomeapi to 19.1.7 (#104644) * Bump aioesphomeapi to 19.1.5 changelog: https://github.com/esphome/aioesphomeapi/compare/v19.1.4...v19.1.5 - Removes the need to watch for BLE connection drops with a seperate future as the library now raises BluetoothConnectionDroppedError when the connection drops during a BLE operation * reduce stack * .6 * tweak * 19.1.7 --- .../components/esphome/bluetooth/client.py | 67 +++++++------------ .../components/esphome/manifest.json | 3 +- requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- 4 files changed, 29 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 22d4392ce31..451c4822d50 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -22,6 +22,7 @@ from aioesphomeapi import ( APIClient, APIVersion, BLEConnectionError, + BluetoothConnectionDroppedError, BluetoothProxyFeature, DeviceInfo, ) @@ -30,7 +31,6 @@ from aioesphomeapi.core import ( BluetoothGATTAPIError, TimeoutAPIError, ) -from async_interrupt import interrupt from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.client import BaseBleakClient, NotifyCallback from bleak.backends.device import BLEDevice @@ -68,39 +68,25 @@ def mac_to_int(address: str) -> int: return int(address.replace(":", ""), 16) -def verify_connected(func: _WrapFuncType) -> _WrapFuncType: - """Define a wrapper throw BleakError if not connected.""" - - async def _async_wrap_bluetooth_connected_operation( - self: ESPHomeClient, *args: Any, **kwargs: Any - ) -> Any: - # pylint: disable=protected-access - if not self._is_connected: - raise BleakError(f"{self._description} is not connected") - loop = self._loop - disconnected_futures = self._disconnected_futures - disconnected_future = loop.create_future() - disconnected_futures.add(disconnected_future) - disconnect_message = f"{self._description}: Disconnected during operation" - try: - async with interrupt(disconnected_future, BleakError, disconnect_message): - return await func(self, *args, **kwargs) - finally: - disconnected_futures.discard(disconnected_future) - - return cast(_WrapFuncType, _async_wrap_bluetooth_connected_operation) - - def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: """Define a wrapper throw esphome api errors as BleakErrors.""" async def _async_wrap_bluetooth_operation( self: ESPHomeClient, *args: Any, **kwargs: Any ) -> Any: + # pylint: disable=protected-access try: return await func(self, *args, **kwargs) except TimeoutAPIError as err: raise asyncio.TimeoutError(str(err)) from err + except BluetoothConnectionDroppedError as ex: + _LOGGER.debug( + "%s: BLE device disconnected during %s operation", + self._description, + func.__name__, + ) + self._async_ble_device_disconnected() + raise BleakError(str(ex)) from ex except BluetoothGATTAPIError as ex: # If the device disconnects in the middle of an operation # be sure to mark it as disconnected so any library using @@ -111,7 +97,6 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: # before the callback is delivered. if ex.error.error == -1: - # pylint: disable=protected-access _LOGGER.debug( "%s: BLE device disconnected during %s operation", self._description, @@ -169,7 +154,6 @@ class ESPHomeClient(BaseBleakClient): self._notify_cancels: dict[ int, tuple[Callable[[], Coroutine[Any, Any, None]], Callable[[], None]] ] = {} - self._disconnected_futures: set[asyncio.Future[None]] = set() self._device_info = client_data.device_info self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( client_data.api_version @@ -185,7 +169,7 @@ class ESPHomeClient(BaseBleakClient): def __str__(self) -> str: """Return the string representation of the client.""" - return f"ESPHomeClient ({self.address})" + return f"ESPHomeClient ({self._description})" def _unsubscribe_connection_state(self) -> None: """Unsubscribe from connection state updates.""" @@ -211,10 +195,6 @@ class ESPHomeClient(BaseBleakClient): for _, notify_abort in self._notify_cancels.values(): notify_abort() self._notify_cancels.clear() - for future in self._disconnected_futures: - if not future.done(): - future.set_result(None) - self._disconnected_futures.clear() self._disconnect_callbacks.discard(self._async_esp_disconnected) self._unsubscribe_connection_state() @@ -406,7 +386,6 @@ class ESPHomeClient(BaseBleakClient): """Get ATT MTU size for active connection.""" return self._mtu or DEFAULT_MTU - @verify_connected @api_error_as_bleak_error async def pair(self, *args: Any, **kwargs: Any) -> bool: """Attempt to pair.""" @@ -415,6 +394,7 @@ class ESPHomeClient(BaseBleakClient): "Pairing is not available in this version ESPHome; " f"Upgrade the ESPHome version on the {self._device_info.name} device." ) + self._raise_if_not_connected() response = await self._client.bluetooth_device_pair(self._address_as_int) if response.paired: return True @@ -423,7 +403,6 @@ class ESPHomeClient(BaseBleakClient): ) return False - @verify_connected @api_error_as_bleak_error async def unpair(self) -> bool: """Attempt to unpair.""" @@ -432,6 +411,7 @@ class ESPHomeClient(BaseBleakClient): "Unpairing is not available in this version ESPHome; " f"Upgrade the ESPHome version on the {self._device_info.name} device." ) + self._raise_if_not_connected() response = await self._client.bluetooth_device_unpair(self._address_as_int) if response.success: return True @@ -454,7 +434,6 @@ class ESPHomeClient(BaseBleakClient): dangerous_use_bleak_cache=dangerous_use_bleak_cache, **kwargs ) - @verify_connected async def _get_services( self, dangerous_use_bleak_cache: bool = False, **kwargs: Any ) -> BleakGATTServiceCollection: @@ -462,6 +441,7 @@ class ESPHomeClient(BaseBleakClient): Must only be called from get_services or connected """ + self._raise_if_not_connected() address_as_int = self._address_as_int cache = self._cache # If the connection version >= 3, we must use the cache @@ -527,7 +507,6 @@ class ESPHomeClient(BaseBleakClient): ) return characteristic - @verify_connected @api_error_as_bleak_error async def clear_cache(self) -> bool: """Clear the GATT cache.""" @@ -541,6 +520,7 @@ class ESPHomeClient(BaseBleakClient): self._device_info.name, ) return True + self._raise_if_not_connected() response = await self._client.bluetooth_device_clear_cache(self._address_as_int) if response.success: return True @@ -551,7 +531,6 @@ class ESPHomeClient(BaseBleakClient): ) return False - @verify_connected @api_error_as_bleak_error async def read_gatt_char( self, @@ -570,12 +549,12 @@ class ESPHomeClient(BaseBleakClient): Returns: (bytearray) The read data. """ + self._raise_if_not_connected() characteristic = self._resolve_characteristic(char_specifier) return await self._client.bluetooth_gatt_read( self._address_as_int, characteristic.handle, GATT_READ_TIMEOUT ) - @verify_connected @api_error_as_bleak_error async def read_gatt_descriptor(self, handle: int, **kwargs: Any) -> bytearray: """Perform read operation on the specified GATT descriptor. @@ -587,11 +566,11 @@ class ESPHomeClient(BaseBleakClient): Returns: (bytearray) The read data. """ + self._raise_if_not_connected() return await self._client.bluetooth_gatt_read_descriptor( self._address_as_int, handle, GATT_READ_TIMEOUT ) - @verify_connected @api_error_as_bleak_error async def write_gatt_char( self, @@ -610,12 +589,12 @@ class ESPHomeClient(BaseBleakClient): response (bool): If write-with-response operation should be done. Defaults to `False`. """ + self._raise_if_not_connected() characteristic = self._resolve_characteristic(characteristic) await self._client.bluetooth_gatt_write( self._address_as_int, characteristic.handle, bytes(data), response ) - @verify_connected @api_error_as_bleak_error async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: """Perform a write operation on the specified GATT descriptor. @@ -624,11 +603,11 @@ class ESPHomeClient(BaseBleakClient): handle (int): The handle of the descriptor to read from. data (bytes or bytearray): The data to send. """ + self._raise_if_not_connected() await self._client.bluetooth_gatt_write_descriptor( self._address_as_int, handle, bytes(data) ) - @verify_connected @api_error_as_bleak_error async def start_notify( self, @@ -655,6 +634,7 @@ class ESPHomeClient(BaseBleakClient): callback (function): The function to be called on notification. kwargs: Unused. """ + self._raise_if_not_connected() ble_handle = characteristic.handle if ble_handle in self._notify_cancels: raise BleakError( @@ -709,7 +689,6 @@ class ESPHomeClient(BaseBleakClient): wait_for_response=False, ) - @verify_connected @api_error_as_bleak_error async def stop_notify( self, @@ -723,6 +702,7 @@ class ESPHomeClient(BaseBleakClient): specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. """ + self._raise_if_not_connected() characteristic = self._resolve_characteristic(char_specifier) # Do not raise KeyError if notifications are not enabled on this characteristic # to be consistent with the behavior of the BlueZ backend @@ -730,6 +710,11 @@ class ESPHomeClient(BaseBleakClient): notify_stop, _ = notify_cancel await notify_stop() + def _raise_if_not_connected(self) -> None: + """Raise a BleakError if not connected.""" + if not self._is_connected: + raise BleakError(f"{self._description} is not connected") + def __del__(self) -> None: """Destructor to make sure the connection state is unsubscribed.""" if self._cancel_connection_state: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 763a7e5b833..93f0e7f6db4 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,8 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "async-interrupt==1.1.1", - "aioesphomeapi==19.1.4", + "aioesphomeapi==19.1.7", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 7241eeb375d..d42f2d5e836 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.4 +aioesphomeapi==19.1.7 # homeassistant.components.flo aioflo==2021.11.0 @@ -463,9 +463,6 @@ asmog==0.0.6 # homeassistant.components.asterisk_mbox asterisk_mbox==0.5.0 -# homeassistant.components.esphome -async-interrupt==1.1.1 - # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 310cd192648..0dec8fb3acd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.4 +aioesphomeapi==19.1.7 # homeassistant.components.flo aioflo==2021.11.0 @@ -415,9 +415,6 @@ aranet4==2.2.2 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 -# homeassistant.components.esphome -async-interrupt==1.1.1 - # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv From 2c196baa7a548291b72283d6acf0d8e0090e545a Mon Sep 17 00:00:00 2001 From: Adrian Huber Date: Tue, 28 Nov 2023 22:24:25 +0100 Subject: [PATCH 823/982] Add DeviceInfo to Wolf SmartSet Entities (#104642) * Fix await warning * Add DeviceInfo to Wolflink sensors * Remove comment * Don't pass device name to DeviceInfo * Use _attr_device_info instead of property --- homeassistant/components/wolflink/__init__.py | 2 +- homeassistant/components/wolflink/const.py | 1 + homeassistant/components/wolflink/sensor.py | 8 +++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index 34df0176e29..73f49a2ad09 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nonlocal refetch_parameters nonlocal parameters await wolf_client.update_session() - if not wolf_client.fetch_system_state_list(device_id, gateway_id): + if not await wolf_client.fetch_system_state_list(device_id, gateway_id): refetch_parameters = True raise UpdateFailed( "Could not fetch values from server because device is Offline." diff --git a/homeassistant/components/wolflink/const.py b/homeassistant/components/wolflink/const.py index ac5bbad48dc..59329ee41dd 100644 --- a/homeassistant/components/wolflink/const.py +++ b/homeassistant/components/wolflink/const.py @@ -7,6 +7,7 @@ PARAMETERS = "parameters" DEVICE_ID = "device_id" DEVICE_GATEWAY = "device_gateway" DEVICE_NAME = "device_name" +MANUFACTURER = "WOLF GmbH" STATES = { "Ein": "ein", diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index b4d60011658..2135239b3eb 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -15,10 +15,11 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import COORDINATOR, DEVICE_ID, DOMAIN, PARAMETERS, STATES +from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES async def async_setup_entry( @@ -60,6 +61,11 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): self._attr_name = wolf_object.name self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" self._state = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + configuration_url="https://www.wolf-smartset.com/", + manufacturer=MANUFACTURER, + ) @property def native_value(self): From 21d842cb5808f85b4ad8738804f490694291c806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 28 Nov 2023 23:44:35 +0200 Subject: [PATCH 824/982] Fix human readable huawei_lte sensor names (#104672) Regression from 7c85d841338e328b73cd2d8d3e3a701bbabf4c31, #98631 --- homeassistant/components/huawei_lte/sensor.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 07486297b32..ee7256340df 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -718,10 +718,6 @@ class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): _unit: str | None = field(default=None, init=False) _last_reset: datetime | None = field(default=None, init=False) - def __post_init__(self) -> None: - """Initialize remaining attributes.""" - self._attr_name = self.entity_description.name or self.item - async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() From bdef0ba6e56be06f50e3f7e4366aaa6f021fc40a Mon Sep 17 00:00:00 2001 From: Alex Hermann Date: Tue, 28 Nov 2023 23:23:49 +0100 Subject: [PATCH 825/982] Significantly improve performance for some cases of the history start time state query (#99450) * recorder: Apply filter in the outer query too Function _get_start_time_state_for_entities_stmt() produced a query which is dead-slow in my installation. On analysis, the outer query produced millions of rows which had to be joined to the subquery. The subquery has a filter which would eliminate almost all of the outer rows. To speed up the query, apply the same filter to the outer query, so way less rows have to be joined. This reduced the query time on my system from more than half an hour to mere milliseconds. * lint * merge filter --------- Co-authored-by: J. Nick Koston --- .../components/recorder/history/modern.py | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 68c357c0ed4..da58822e266 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -527,31 +527,37 @@ def _get_start_time_state_for_entities_stmt( ) -> Select: """Baked query to get states for specific entities.""" # We got an include-list of entities, accelerate the query by filtering already - # in the inner query. - stmt = _stmt_and_join_attributes_for_start_state( - no_attributes, include_last_changed - ).join( - ( - most_recent_states_for_entities_by_date := ( - select( - States.metadata_id.label("max_metadata_id"), - func.max(States.last_updated_ts).label("max_last_updated"), + # in the inner and the outer query. + stmt = ( + _stmt_and_join_attributes_for_start_state(no_attributes, include_last_changed) + .join( + ( + most_recent_states_for_entities_by_date := ( + select( + States.metadata_id.label("max_metadata_id"), + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < epoch_time) + & States.metadata_id.in_(metadata_ids) + ) + .group_by(States.metadata_id) + .subquery() ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < epoch_time) - ) - .filter(States.metadata_id.in_(metadata_ids)) - .group_by(States.metadata_id) - .subquery() - ) - ), - and_( - States.metadata_id - == most_recent_states_for_entities_by_date.c.max_metadata_id, - States.last_updated_ts - == most_recent_states_for_entities_by_date.c.max_last_updated, - ), + ), + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < epoch_time) + & States.metadata_id.in_(metadata_ids) + ) ) if no_attributes: return stmt From de3b608e785b5538ba1a01019932d899b0d407d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Nov 2023 16:38:00 -0600 Subject: [PATCH 826/982] Remove BLE connection state unsubscribe workaround from ESPHome (#104674) aioesphomeapi now has explict coverage to ensure calling the unsubscribe function after the connection drops is safe and will not raise anymore --- .../components/esphome/bluetooth/client.py | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 451c4822d50..96f1bce686a 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -171,23 +171,6 @@ class ESPHomeClient(BaseBleakClient): """Return the string representation of the client.""" return f"ESPHomeClient ({self._description})" - def _unsubscribe_connection_state(self) -> None: - """Unsubscribe from connection state updates.""" - if not self._cancel_connection_state: - return - try: - self._cancel_connection_state() - except (AssertionError, ValueError) as ex: - _LOGGER.debug( - ( - "%s: Failed to unsubscribe from connection state (likely" - " connection dropped): %s" - ), - self._description, - ex, - ) - self._cancel_connection_state = None - def _async_disconnected_cleanup(self) -> None: """Clean up on disconnect.""" self.services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] @@ -196,7 +179,9 @@ class ESPHomeClient(BaseBleakClient): notify_abort() self._notify_cancels.clear() self._disconnect_callbacks.discard(self._async_esp_disconnected) - self._unsubscribe_connection_state() + if self._cancel_connection_state: + self._cancel_connection_state() + self._cancel_connection_state = None def _async_ble_device_disconnected(self) -> None: """Handle the BLE device disconnecting from the ESP.""" From 3c25d9548177fa872f54c5b05a8c76669ee9359d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Nov 2023 21:57:39 -0600 Subject: [PATCH 827/982] Bump aioesphomeapi to 19.2.0 (#104677) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 93f0e7f6db4..26d15da680b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==19.1.7", + "aioesphomeapi==19.2.0", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index d42f2d5e836..64349902be8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.7 +aioesphomeapi==19.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0dec8fb3acd..6be6f60474e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.1.7 +aioesphomeapi==19.2.0 # homeassistant.components.flo aioflo==2021.11.0 From 017d05c03ef5dffff3150c8fa980a19d174940dd Mon Sep 17 00:00:00 2001 From: Stefan Rado <628587+kroimon@users.noreply.github.com> Date: Wed, 29 Nov 2023 05:57:30 +0100 Subject: [PATCH 828/982] Add humidity and aux heat support to ESPHome climate entities (#103807) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/climate.py | 38 ++++++ tests/components/esphome/test_climate.py | 131 +++++++++++++++++++- 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index b34714ff89c..73b326204b5 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -164,11 +164,17 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti ) self._attr_min_temp = static_info.visual_min_temperature self._attr_max_temp = static_info.visual_max_temperature + self._attr_min_humidity = round(static_info.visual_min_humidity) + self._attr_max_humidity = round(static_info.visual_max_humidity) features = ClimateEntityFeature(0) if self._static_info.supports_two_point_target_temperature: features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE else: features |= ClimateEntityFeature.TARGET_TEMPERATURE + if self._static_info.supports_target_humidity: + features |= ClimateEntityFeature.TARGET_HUMIDITY + if self._static_info.supports_aux_heat: + features |= ClimateEntityFeature.AUX_HEAT if self.preset_modes: features |= ClimateEntityFeature.PRESET_MODE if self.fan_modes: @@ -234,6 +240,14 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """Return the current temperature.""" return self._state.current_temperature + @property + @esphome_state_property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + if not self._static_info.supports_current_humidity: + return None + return round(self._state.current_humidity) + @property @esphome_state_property def target_temperature(self) -> float | None: @@ -252,6 +266,18 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """Return the highbound target temperature we try to reach.""" return self._state.target_temperature_high + @property + @esphome_state_property + def target_humidity(self) -> int: + """Return the humidity we try to reach.""" + return round(self._state.target_humidity) + + @property + @esphome_state_property + def is_aux_heat(self) -> bool: + """Return the auxiliary heater state.""" + return self._state.aux_heat + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature (and operation mode if set).""" data: dict[str, Any] = {"key": self._key} @@ -267,6 +293,10 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] await self._client.climate_command(**data) + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self._client.climate_command(key=self._key, target_humidity=humidity) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" await self._client.climate_command( @@ -296,3 +326,11 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti await self._client.climate_command( key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) ) + + async def async_turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + await self._client.climate_command(key=self._key, aux_heat=True) + + async def async_turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + await self._client.climate_command(key=self._key, aux_heat=False) diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 7e00fd22a1c..8f0b8f96c56 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -15,8 +15,13 @@ from aioesphomeapi import ( ) from homeassistant.components.climate import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, ATTR_FAN_MODE, + ATTR_HUMIDITY, ATTR_HVAC_MODE, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, ATTR_PRESET_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, @@ -24,7 +29,9 @@ from homeassistant.components.climate import ( ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, FAN_HIGH, + SERVICE_SET_AUX_HEAT, SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, @@ -32,7 +39,7 @@ from homeassistant.components.climate import ( SWING_BOTH, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant @@ -312,3 +319,125 @@ async def test_climate_entity_with_step_and_target_temp( [call(key=1, swing_mode=ClimateSwingMode.BOTH)] ) mock_client.climate_command.reset_mock() + + +async def test_climate_entity_with_aux_heat( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic climate entity with aux heat.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supports_aux_heat=True, + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.HEAT, + action=ClimateAction.HEATING, + current_temperature=30, + target_temperature=20, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + aux_heat=True, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_myclimate") + assert state is not None + assert state.state == HVACMode.HEAT + attributes = state.attributes + assert attributes[ATTR_AUX_HEAT] == STATE_ON + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_AUX_HEAT: False}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, aux_heat=False)]) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_AUX_HEAT: True}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, aux_heat=True)]) + mock_client.climate_command.reset_mock() + + +async def test_climate_entity_with_humidity( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic climate entity with humidity.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supports_current_humidity=True, + supports_target_humidity=True, + visual_min_humidity=10.1, + visual_max_humidity=29.7, + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.AUTO, + action=ClimateAction.COOLING, + current_temperature=30, + target_temperature=20, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + current_humidity=20.1, + target_humidity=25.7, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_myclimate") + assert state is not None + assert state.state == HVACMode.AUTO + attributes = state.attributes + assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert attributes[ATTR_HUMIDITY] == 26 + assert attributes[ATTR_MAX_HUMIDITY] == 30 + assert attributes[ATTR_MIN_HUMIDITY] == 10 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_HUMIDITY: 23}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, target_humidity=23)]) + mock_client.climate_command.reset_mock() From a9a95ad8819b0c93c564b5c34daccf7fee9e758c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 29 Nov 2023 08:42:02 +0100 Subject: [PATCH 829/982] Revert "Introduce base entity for ping" (#104682) --- .../components/ping/binary_sensor.py | 12 ++++-- .../components/ping/device_tracker.py | 42 ++++++++++++------- homeassistant/components/ping/entity.py | 28 ------------- .../ping/snapshots/test_binary_sensor.ambr | 34 ++------------- tests/components/ping/test_binary_sensor.py | 11 +---- tests/components/ping/test_device_tracker.py | 23 ---------- 6 files changed, 39 insertions(+), 111 deletions(-) delete mode 100644 homeassistant/components/ping/entity.py diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index e6cad32f3de..97636111586 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -18,11 +18,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN from .coordinator import PingUpdateCoordinator -from .entity import BasePingEntity _LOGGER = logging.getLogger(__name__) @@ -84,16 +84,20 @@ async def async_setup_entry( async_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) -class PingBinarySensor(BasePingEntity, BinarySensorEntity): +class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEntity): """Representation of a Ping Binary sensor.""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_available = False def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping Binary sensor.""" - super().__init__(config_entry, coordinator) + super().__init__(coordinator) + + self._attr_name = config_entry.title + self._attr_unique_id = config_entry.entry_id # if this was imported just enable it when it was enabled before if CONF_IMPORTED_BY in config_entry.data: @@ -108,7 +112,7 @@ class PingBinarySensor(BasePingEntity, BinarySensorEntity): @property def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the ICMP echo request.""" + """Return the state attributes of the ICMP checo request.""" if self.coordinator.data.data is None: return None return { diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 1bce965ee55..ceff1b2e124 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -8,26 +8,21 @@ import voluptuous as vol from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, AsyncSeeCallback, + ScannerEntity, SourceType, ) -from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_HOSTS, - CONF_NAME, - STATE_HOME, - STATE_NOT_HOME, -) +from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN -from .entity import BasePingEntity +from .coordinator import PingUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -89,20 +84,37 @@ async def async_setup_entry( async_add_entities([PingDeviceTracker(entry, data.coordinators[entry.entry_id])]) -class PingDeviceTracker(BasePingEntity, BaseTrackerEntity): +class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" + def __init__( + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + ) -> None: + """Initialize the Ping device tracker.""" + super().__init__(coordinator) + + self._attr_name = config_entry.title + self.config_entry = config_entry + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self.coordinator.data.ip_address + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self.config_entry.entry_id + @property def source_type(self) -> SourceType: """Return the source type which is router.""" return SourceType.ROUTER @property - def state(self) -> str: - """Return the state of the device.""" - if self.coordinator.data.is_alive: - return STATE_HOME - return STATE_NOT_HOME + def is_connected(self) -> bool: + """Return true if ping returns is_alive.""" + return self.coordinator.data.is_alive @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/ping/entity.py b/homeassistant/components/ping/entity.py deleted file mode 100644 index 058d8c967e5..00000000000 --- a/homeassistant/components/ping/entity.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Base entity for Ping integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import PingUpdateCoordinator - - -class BasePingEntity(CoordinatorEntity[PingUpdateCoordinator]): - """Representation of a Ping base entity.""" - - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator - ) -> None: - """Initialize the Ping Binary sensor.""" - super().__init__(coordinator) - - self._attr_unique_id = config_entry.entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.coordinator.data.ip_address)}, - manufacturer="Ping", - ) - - self.config_entry = config_entry diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index f570a8afc51..2ce320d561b 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -72,7 +72,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.10_10_10_10', - 'has_entity_name': True, + 'has_entity_name': False, 'hidden_by': None, 'icon': None, 'id': , @@ -81,7 +81,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, + 'original_name': '10.10.10.10', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -90,34 +90,6 @@ }) # --- # name: test_setup_and_update.1 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'ping', - '10.10.10.10', - ), - }), - 'is_new': False, - 'manufacturer': 'Ping', - 'model': None, - 'name': '10.10.10.10', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_setup_and_update.2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -134,7 +106,7 @@ 'state': 'on', }) # --- -# name: test_setup_and_update.3 +# name: test_setup_and_update.2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index 5eab92b1139..b1066895e2b 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -10,11 +10,7 @@ from syrupy.filters import props from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -24,7 +20,6 @@ from tests.common import MockConfigEntry async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: @@ -34,10 +29,6 @@ async def test_setup_and_update( entry = entity_registry.async_get("binary_sensor.10_10_10_10") assert entry == snapshot(exclude=props("unique_id")) - # check the device - device = device_registry.async_get_device({(DOMAIN, "10.10.10.10")}) - assert device == snapshot - state = hass.states.get("binary_sensor.10_10_10_10") assert state == snapshot diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index a180e8d745e..b6cc6b42912 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,9 +1,5 @@ """Test the binary sensor platform of ping.""" -from datetime import timedelta -from unittest.mock import patch -from freezegun.api import FrozenDateTimeFactory -from icmplib import Host import pytest from homeassistant.components.ping.const import DOMAIN @@ -19,7 +15,6 @@ async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test sensor setup and update.""" @@ -47,24 +42,6 @@ async def test_setup_and_update( state = hass.states.get("device_tracker.10_10_10_10") assert state.state == "home" - freezer.tick(timedelta(minutes=5)) - await hass.async_block_till_done() - - # check device tracker is still "home" - state = hass.states.get("device_tracker.10_10_10_10") - assert state.state == "home" - - # check if device tracker updates to "not home" - with patch( - "homeassistant.components.ping.helpers.async_ping", - return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), - ): - freezer.tick(timedelta(minutes=5)) - await hass.async_block_till_done() - - state = hass.states.get("device_tracker.10_10_10_10") - assert state.state == "not_home" - async def test_import_issue_creation( hass: HomeAssistant, From 68722ce66265559e1a4d4856c88f8b2f26665965 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 29 Nov 2023 08:43:48 +0100 Subject: [PATCH 830/982] Bump aioairq to 0.3.1 (#104659) --- homeassistant/components/airq/const.py | 1 - homeassistant/components/airq/coordinator.py | 6 ++---- homeassistant/components/airq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airq/test_config_flow.py | 2 +- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 82719515cbf..d1a2340b4bc 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -3,7 +3,6 @@ from typing import Final DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" -TARGET_ROUTE: Final = "average" CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 2d0d9d199df..76459005c45 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL +from .const import DOMAIN, MANUFACTURER, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -56,6 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - - data = await self.airq.get(TARGET_ROUTE) - return self.airq.drop_uncertainties_from_data(data) + return await self.airq.get_latest_data() diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 97fb70c1b05..156f167913b 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.2.4"] + "requirements": ["aioairq==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 64349902be8..6f1cbffe6bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.1 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6be6f60474e..a4651fe495d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.1 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 252c12f80fa..52fc8d2300b 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,7 @@ """Test the air-Q config flow.""" from unittest.mock import patch -from aioairq.core import DeviceInfo, InvalidAuth, InvalidInput +from aioairq import DeviceInfo, InvalidAuth, InvalidInput from aiohttp.client_exceptions import ClientConnectionError import pytest From 4b667cff26476ba6a8be03f64d3eacc4954b6682 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 08:44:28 +0100 Subject: [PATCH 831/982] Host field description: implement review from #104658 (#104685) --- homeassistant/components/airtouch4/strings.json | 2 +- homeassistant/components/airvisual_pro/strings.json | 2 +- homeassistant/components/alarmdecoder/strings.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json index c810b991b8e..04c2e54cc7e 100644 --- a/homeassistant/components/airtouch4/strings.json +++ b/homeassistant/components/airtouch4/strings.json @@ -14,7 +14,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of the device running your AirTouch controller." + "host": "The hostname or IP address of your AirTouch controller." } } } diff --git a/homeassistant/components/airvisual_pro/strings.json b/homeassistant/components/airvisual_pro/strings.json index 6f690c9eb4a..641fa8963da 100644 --- a/homeassistant/components/airvisual_pro/strings.json +++ b/homeassistant/components/airvisual_pro/strings.json @@ -14,7 +14,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "The hostname or IP address of the device running your AirVisual Pro." + "ip_address": "The hostname or IP address of your AirVisual Pro device." } } }, diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index e1b5a774a87..dd698201b09 100644 --- a/homeassistant/components/alarmdecoder/strings.json +++ b/homeassistant/components/alarmdecoder/strings.json @@ -16,7 +16,7 @@ "device_path": "Device Path" }, "data_description": { - "host": "The hostname or IP address of the machine connected to the AlarmDecoder device.", + "host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.", "port": "The port on which AlarmDecoder is accessible (for example, 10000)" } } From 3aa9066a507f7407ce232165a5ee34a017277cfc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 29 Nov 2023 08:45:47 +0100 Subject: [PATCH 832/982] Add field description for Shelly host (#104686) --- homeassistant/components/shelly/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b12ad3e4823..49c66a56459 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -6,6 +6,9 @@ "description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Shelly device to control." } }, "credentials": { From 526180a8afcbf76940d5e3db81cb9f34f031cf42 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Wed, 29 Nov 2023 03:08:27 -0500 Subject: [PATCH 833/982] Add PECO smart meter binary_sensor (#71034) * Add support for PECO smart meter * Add support for PECO smart meter * Conform to black * Fix tests and additional clean-up * Return init file to original state * Move to FlowResultType * Catch up to upstream * Remove commented code * isort * Merge smart meter and outage count into one entry * Test coverage * Remove logging exceptions from config flow verification * Fix comments from @emontnemery * Revert "Add support for PECO smart meter" This reverts commit 36ca90856684f328e71bc3778fa7aa52a6bde5ca. * More fixes --- homeassistant/components/peco/__init__.py | 58 +++++- .../components/peco/binary_sensor.py | 59 ++++++ homeassistant/components/peco/config_flow.py | 91 ++++++++- homeassistant/components/peco/const.py | 4 +- homeassistant/components/peco/sensor.py | 2 +- homeassistant/components/peco/strings.json | 18 +- tests/components/peco/test_config_flow.py | 176 +++++++++++++++--- tests/components/peco/test_init.py | 160 +++++++++++++++- 8 files changed, 525 insertions(+), 43 deletions(-) create mode 100644 homeassistant/components/peco/binary_sensor.py diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index ad74200dace..bcdc4195100 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -5,7 +5,14 @@ from dataclasses import dataclass from datetime import timedelta from typing import Final -from peco import AlertResults, BadJSONError, HttpError, OutageResults, PecoOutageApi +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + PecoOutageApi, + UnresponsiveMeterError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -13,9 +20,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_COUNTY, DOMAIN, LOGGER, SCAN_INTERVAL +from .const import ( + CONF_COUNTY, + CONF_PHONE_NUMBER, + DOMAIN, + LOGGER, + OUTAGE_SCAN_INTERVAL, + SMART_METER_SCAN_INTERVAL, +) -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.SENSOR, Platform.BINARY_SENSOR] @dataclass @@ -31,9 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass) api = PecoOutageApi() + # Outage Counter Setup county: str = entry.data[CONF_COUNTY] - async def async_update_data() -> PECOCoordinatorData: + async def async_update_outage_data() -> OutageResults: """Fetch data from API.""" try: outages: OutageResults = ( @@ -53,15 +68,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, LOGGER, name="PECO Outage Count", - update_method=async_update_data, - update_interval=timedelta(minutes=SCAN_INTERVAL), + update_method=async_update_outage_data, + update_interval=timedelta(minutes=OUTAGE_SCAN_INTERVAL), ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"outage_count": coordinator} + if phone_number := entry.data.get(CONF_PHONE_NUMBER): + # Smart Meter Setup] + + async def async_update_meter_data() -> bool: + """Fetch data from API.""" + try: + data: bool = await api.meter_check(phone_number, websession) + except UnresponsiveMeterError as err: + raise UpdateFailed("Unresponsive meter") from err + except HttpError as err: + raise UpdateFailed(f"Error fetching data: {err}") from err + except BadJSONError as err: + raise UpdateFailed(f"Error parsing data: {err}") from err + return data + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="PECO Smart Meter", + update_method=async_update_meter_data, + update_interval=timedelta(minutes=SMART_METER_SCAN_INTERVAL), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id]["smart_meter"] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py new file mode 100644 index 00000000000..7f0402b207f --- /dev/null +++ b/homeassistant/components/peco/binary_sensor.py @@ -0,0 +1,59 @@ +"""Binary sensor for PECO outage counter.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +PARALLEL_UPDATES: Final = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor for PECO.""" + if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: + return + coordinator: DataUpdateCoordinator[bool] = hass.data[DOMAIN][config_entry.entry_id][ + "smart_meter" + ] + + async_add_entities( + [PecoBinarySensor(coordinator, phone_number=config_entry.data["phone_number"])] + ) + + +class PecoBinarySensor( + CoordinatorEntity[DataUpdateCoordinator[bool]], BinarySensorEntity +): + """Binary sensor for PECO outage counter.""" + + _attr_icon = "mdi:gauge" + _attr_device_class = BinarySensorDeviceClass.POWER + _attr_name = "Meter Status" + + def __init__( + self, coordinator: DataUpdateCoordinator[bool], phone_number: str + ) -> None: + """Initialize binary sensor for PECO.""" + super().__init__(coordinator) + self._attr_unique_id = f"{phone_number}" + + @property + def is_on(self) -> bool: + """Return if the meter has power.""" + return self.coordinator.data diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index 63ca7f3291a..261cdb031bf 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -1,41 +1,122 @@ """Config flow for PECO Outage Counter integration.""" from __future__ import annotations +import logging from typing import Any +from peco import ( + HttpError, + IncompatibleMeterError, + PecoOutageApi, + UnresponsiveMeterError, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv -from .const import CONF_COUNTY, COUNTY_LIST, DOMAIN +from .const import CONF_COUNTY, CONF_PHONE_NUMBER, COUNTY_LIST, DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_COUNTY): vol.In(COUNTY_LIST), + vol.Optional(CONF_PHONE_NUMBER): cv.string, } ) +_LOGGER = logging.getLogger(__name__) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PECO Outage Counter.""" VERSION = 1 + meter_verification: bool = False + meter_data: dict[str, str] = {} + meter_error: dict[str, str] = {} + + async def _verify_meter(self, phone_number: str) -> None: + """Verify if the meter is compatible.""" + + api = PecoOutageApi() + + try: + await api.meter_check(phone_number) + except ValueError: + self.meter_error = {"phone_number": "invalid_phone_number", "type": "error"} + except IncompatibleMeterError: + self.meter_error = {"phone_number": "incompatible_meter", "type": "abort"} + except UnresponsiveMeterError: + self.meter_error = {"phone_number": "unresponsive_meter", "type": "error"} + except HttpError: + self.meter_error = {"phone_number": "http_error", "type": "error"} + + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" + if self.meter_verification is True: + return self.async_show_progress_done(next_step_id="finish_smart_meter") + if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, ) county = user_input[CONF_COUNTY] - await self.async_set_unique_id(county) + if CONF_PHONE_NUMBER not in user_input: + await self.async_set_unique_id(county) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{user_input[CONF_COUNTY].capitalize()} Outage Count", + data=user_input, + ) + + phone_number = user_input[CONF_PHONE_NUMBER] + + await self.async_set_unique_id(f"{county}-{phone_number}") self._abort_if_unique_id_configured() - return self.async_create_entry( - title=f"{county.capitalize()} Outage Count", data=user_input + self.meter_verification = True + + if self.meter_error is not None: + # Clear any previous errors, since the user may have corrected them + self.meter_error = {} + + self.hass.async_create_task(self._verify_meter(phone_number)) + + self.meter_data = user_input + + return self.async_show_progress( + step_id="user", + progress_action="verifying_meter", + ) + + async def async_step_finish_smart_meter( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the finish smart meter step.""" + if "phone_number" in self.meter_error: + if self.meter_error["type"] == "error": + self.meter_verification = False + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"phone_number": self.meter_error["phone_number"]}, + ) + + return self.async_abort(reason=self.meter_error["phone_number"]) + + return self.async_create_entry( + title=f"{self.meter_data[CONF_COUNTY].capitalize()} - {self.meter_data[CONF_PHONE_NUMBER]}", + data=self.meter_data, ) diff --git a/homeassistant/components/peco/const.py b/homeassistant/components/peco/const.py index b0198ac8761..1df8ae41ecb 100644 --- a/homeassistant/components/peco/const.py +++ b/homeassistant/components/peco/const.py @@ -14,6 +14,8 @@ COUNTY_LIST: Final = [ "TOTAL", ] CONFIG_FLOW_COUNTIES: Final = [{county: county.capitalize()} for county in COUNTY_LIST] -SCAN_INTERVAL: Final = 9 +OUTAGE_SCAN_INTERVAL: Final = 9 # minutes +SMART_METER_SCAN_INTERVAL: Final = 15 # minutes CONF_COUNTY: Final = "county" ATTR_CONTENT: Final = "content" +CONF_PHONE_NUMBER: Final = "phone_number" diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index 5be41f7c7e1..935f2b659f9 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -91,7 +91,7 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" county: str = config_entry.data[CONF_COUNTY] - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id]["outage_count"] async_add_entities( PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST diff --git a/homeassistant/components/peco/strings.json b/homeassistant/components/peco/strings.json index 059b2ba71a7..cdf5bb497db 100644 --- a/homeassistant/components/peco/strings.json +++ b/homeassistant/components/peco/strings.json @@ -3,12 +3,26 @@ "step": { "user": { "data": { - "county": "County" + "county": "County", + "phone_number": "Phone Number" + }, + "data_description": { + "county": "County used for outage number retrieval", + "phone_number": "Phone number associated with the PECO account (optional). Adding a phone number adds a binary sensor confirming if your power is out or not, and not an issue with a breaker or an issue on your end." } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "incompatible_meter": "Your meter is not compatible with smart meter checking." + }, + "progress": { + "verifying_meter": "One moment. Verifying that your meter is compatible. This may take a minute or two." + }, + "error": { + "invalid_phone_number": "Please enter a valid phone number.", + "unresponsive_meter": "Your meter is not responding. Please try again later.", + "http_error": "There was an error communicating with PECO. The issue that is most likely is that you entered an invalid phone number. Please check the phone number or try again later." } }, "entity": { diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index 532450f0099..ca6759baeff 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -1,6 +1,7 @@ """Test the PECO Outage Counter config flow.""" from unittest.mock import patch +from peco import HttpError, IncompatibleMeterError, UnresponsiveMeterError import pytest from voluptuous.error import MultipleInvalid @@ -17,6 +18,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM assert result["errors"] is None + assert result["step_id"] == "user" with patch( "homeassistant.components.peco.async_setup_entry", @@ -35,6 +37,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "county": "PHILADELPHIA", } + assert result2["context"]["unique_id"] == "PHILADELPHIA" async def test_invalid_county(hass: HomeAssistant) -> None: @@ -43,37 +46,160 @@ async def test_invalid_county(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - with pytest.raises(MultipleInvalid): - await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "county": "INVALID_COUNTY_THAT_SHOULD_NOT_EXIST", - }, - ) - await hass.async_block_till_done() - - second_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert second_result["type"] == FlowResultType.FORM - assert second_result["errors"] is None + assert result["step_id"] == "user" with patch( "homeassistant.components.peco.async_setup_entry", return_value=True, - ): - second_result2 = await hass.config_entries.flow.async_configure( - second_result["flow_id"], + ), pytest.raises(MultipleInvalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], { - "county": "PHILADELPHIA", + "county": "INVALID_COUNTY_THAT_SHOULDNT_EXIST", }, ) await hass.async_block_till_done() - assert second_result2["type"] == FlowResultType.CREATE_ENTRY - assert second_result2["title"] == "Philadelphia Outage Count" - assert second_result2["data"] == { - "county": "PHILADELPHIA", - } + +async def test_meter_value_error(hass: HomeAssistant) -> None: + """Test if the MeterValueError error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "INVALID_SMART_METER_THAT_SHOULD_NOT_EXIST", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "invalid_phone_number"} + + +async def test_incompatible_meter_error(hass: HomeAssistant) -> None: + """Test if the IncompatibleMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=IncompatibleMeterError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "incompatible_meter" + + +async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: + """Test if the UnresponsiveMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=UnresponsiveMeterError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "unresponsive_meter"} + + +async def test_meter_http_error(hass: HomeAssistant) -> None: + """Test if the InvalidMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=HttpError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "http_error"} + + +async def test_smart_meter(hass: HomeAssistant) -> None: + """Test if the Smart Meter step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Philadelphia - 1234567890" + assert result["data"]["phone_number"] == "1234567890" + assert result["context"]["unique_id"] == "PHILADELPHIA-1234567890" diff --git a/tests/components/peco/test_init.py b/tests/components/peco/test_init.py index 52a7ddd3b25..2919e508c97 100644 --- a/tests/components/peco/test_init.py +++ b/tests/components/peco/test_init.py @@ -2,7 +2,13 @@ import asyncio from unittest.mock import patch -from peco import AlertResults, BadJSONError, HttpError, OutageResults +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + UnresponsiveMeterError, +) import pytest from homeassistant.components.peco.const import DOMAIN @@ -14,6 +20,7 @@ from tests.common import MockConfigEntry MOCK_ENTRY_DATA = {"county": "TOTAL"} COUNTY_ENTRY_DATA = {"county": "BUCKS"} INVALID_COUNTY_DATA = {"county": "INVALID"} +METER_DATA = {"county": "BUCKS", "phone_number": "1234567890"} async def test_unload_entry(hass: HomeAssistant) -> None: @@ -149,3 +156,154 @@ async def test_bad_json(hass: HomeAssistant, sensor: str) -> None: assert hass.states.get(f"sensor.{sensor}") is None assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: + """Test if it raises an error when the meter will not respond.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=UnresponsiveMeterError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_http_error(hass: HomeAssistant) -> None: + """Test if it raises an error when there is an HTTP error.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=HttpError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_bad_json(hass: HomeAssistant) -> None: + """Test if it raises an error when there is bad JSON.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=BadJSONError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_timeout(hass: HomeAssistant) -> None: + """Test if it raises an error when there is a timeout.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=asyncio.TimeoutError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_data(hass: HomeAssistant) -> None: + """Test if the meter returns the value successfully.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + return_value=True, + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is not None + assert hass.states.get("binary_sensor.meter_status").state == "on" + assert config_entry.state == ConfigEntryState.LOADED From 8e8e8077a099afc208544111216fa7fe04daea0d Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:13:35 +0100 Subject: [PATCH 834/982] Agent DVR and Android IP webcam: Add description of host field (#104688) --- homeassistant/components/agent_dvr/strings.json | 3 +++ homeassistant/components/android_ip_webcam/strings.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/agent_dvr/strings.json b/homeassistant/components/agent_dvr/strings.json index 77167b8294b..cbfc2e87a4d 100644 --- a/homeassistant/components/agent_dvr/strings.json +++ b/homeassistant/components/agent_dvr/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The IP address of the Agent DVR server." } } }, diff --git a/homeassistant/components/android_ip_webcam/strings.json b/homeassistant/components/android_ip_webcam/strings.json index db21a690984..57e5452b900 100644 --- a/homeassistant/components/android_ip_webcam/strings.json +++ b/homeassistant/components/android_ip_webcam/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The IP address of the device running the Android IP Webcam app. The IP address is shown in the app once you start the server." } } }, From 5dc64dd6b9532028c1b6c1fa49c90fd852c11cd6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:16:58 +0100 Subject: [PATCH 835/982] Fix HA state update in ViCare number platform (#104687) use sync update fn --- homeassistant/components/vicare/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 45abd0a5cda..f8abea13401 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -144,7 +144,7 @@ class ViCareNumber(ViCareEntity, NumberEntity): """Set new value.""" if self.entity_description.value_setter: self.entity_description.value_setter(self._api, value) - self.async_write_ha_state() + self.schedule_update_ha_state() def update(self) -> None: """Update state of number.""" From 2663a4d61758e68379eac673fc6216857848b055 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Nov 2023 09:19:02 +0100 Subject: [PATCH 836/982] Bump zha-quirks to 0.0.107 (#104683) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/zha_devices_list.py | 10 ++++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index af2c8405e5f..786caf1809c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -24,7 +24,7 @@ "bellows==0.36.8", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.106", + "zha-quirks==0.0.107", "zigpy-deconz==0.21.1", "zigpy==0.59.0", "zigpy-xbee==0.19.0", diff --git a/requirements_all.txt b/requirements_all.txt index 6f1cbffe6bc..cb7964e7c2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2819,7 +2819,7 @@ zeroconf==0.127.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.107 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4651fe495d..f646b41e3cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2114,7 +2114,7 @@ zeroconf==0.127.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.107 # homeassistant.components.zha zigpy-deconz==0.21.1 diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index c193cd509f3..65ef55c4711 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -2178,6 +2178,16 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power_factor", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_instantaneous_demand", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", From c4e3ae84f438b6c7d1b4b754e1f026e0b1a38356 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:28:56 +0100 Subject: [PATCH 837/982] Optimize async executor use in ViCare integration (#104645) * use one async executor * use list comprehension * simplify * simplify * simplify * simplify * simplify * simplify * simplify * simplify * add type * Apply suggestions from code review * fix ruff findings --- .../components/vicare/binary_sensor.py | 119 ++++++++++-------- homeassistant/components/vicare/button.py | 39 +++--- homeassistant/components/vicare/climate.py | 35 ++++-- homeassistant/components/vicare/number.py | 44 +++---- homeassistant/components/vicare/sensor.py | 96 ++++++++++---- .../components/vicare/water_heater.py | 31 +++-- 6 files changed, 218 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index f92f24d01ce..525099e7d4e 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -7,6 +7,9 @@ import logging from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -104,39 +107,67 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ) -def _build_entity( - vicare_api: PyViCareDevice, +def _build_entities( + device: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareBinarySensorEntityDescription, -): - """Create a ViCare binary sensor entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareBinarySensor( - vicare_api, - device_config, - entity_description, +) -> list[ViCareBinarySensor]: + """Create ViCare binary sensor entities for a device.""" + + entities: list[ViCareBinarySensor] = _build_entities_for_device( + device, device_config + ) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS ) - return None + ) + entities.extend( + _build_entities_for_component( + get_burners(device), device_config, BURNER_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_compressors(device), device_config, COMPRESSOR_SENSORS + ) + ) + return entities -async def _entities_from_descriptions( - hass: HomeAssistant, - entities: list[ViCareBinarySensor], - sensor_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], - iterables, - config_entry: ConfigEntry, -) -> None: - """Create entities from descriptions and list of burners/circuits.""" - for description in sensor_descriptions: - for current in iterables: - entity = await hass.async_add_executor_job( - _build_entity, - current, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, - ) - if entity: - entities.append(entity) +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareBinarySensor]: + """Create device specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + device, + device_config, + description, + ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] + + +def _build_entities_for_component( + components: list[PyViCareHeatingDeviceWithComponent], + device_config: PyViCareDeviceConfig, + entity_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], +) -> list[ViCareBinarySensor]: + """Create component specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + component, + device_config, + description, + ) + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] async def async_setup_entry( @@ -146,36 +177,16 @@ async def async_setup_entry( ) -> None: """Create the ViCare binary sensor devices.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - entities = [] - - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity: - entities.append(entity) - - circuits = await hass.async_add_executor_job(get_circuits, api) - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, circuits, config_entry ) - burners = await hass.async_add_executor_job(get_burners, api) - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, burners, config_entry - ) - - compressors = await hass.async_add_executor_job(get_compressors, api) - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, compressors, config_entry - ) - - async_add_entities(entities) - class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): """Representation of a ViCare sensor.""" diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index d0e50b5f772..374d98b3397 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -47,19 +47,21 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( ) -def _build_entity( - vicare_api: PyViCareDevice, +def _build_entities( + api: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareButtonEntityDescription, -): - """Create a ViCare button entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareButton( - vicare_api, +) -> list[ViCareButton]: + """Create ViCare button entities for a device.""" + + return [ + ViCareButton( + api, device_config, - entity_description, + description, ) - return None + for description in BUTTON_DESCRIPTIONS + if is_supported(description.key, description, api) + ] async def async_setup_entry( @@ -69,20 +71,15 @@ async def async_setup_entry( ) -> None: """Create the ViCare button entities.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - entities = [] - - for description in BUTTON_DESCRIPTIONS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity: - entities.append(entity) - - async_add_entities(entities) + ) class ViCareButton(ViCareEntity, ButtonEntity): diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index c1e04e1d1b2..c14f940ffe6 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -95,25 +95,30 @@ HA_TO_VICARE_PRESET_HEATING = { } +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareClimate]: + """Create ViCare climate entities for a device.""" + return [ + ViCareClimate( + api, + circuit, + device_config, + "heating", + ) + for circuit in get_circuits(api) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - - for circuit in circuits: - entity = ViCareClimate( - api, - circuit, - device_config, - "heating", - ) - entities.append(entity) platform = entity_platform.async_get_current_platform() @@ -123,7 +128,13 @@ async def async_setup_entry( "set_vicare_mode", ) - async_add_entities(entities) + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + api, + device_config, + ) + ) class ViCareClimate(ViCareEntity, ClimateEntity): diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index f8abea13401..5511f2a5294 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -80,19 +80,22 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( ) -def _build_entity( - vicare_api: PyViCareHeatingDeviceComponent, +def _build_entities( + api: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareNumberEntityDescription, -) -> ViCareNumber | None: - """Create a ViCare number entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareNumber( - vicare_api, +) -> list[ViCareNumber]: + """Create ViCare number entities for a component.""" + + return [ + ViCareNumber( + circuit, device_config, - entity_description, + description, ) - return None + for circuit in get_circuits(api) + for description in CIRCUIT_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, circuit) + ] async def async_setup_entry( @@ -101,23 +104,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare number devices.""" - entities: list[ViCareNumber] = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - for circuit in circuits: - for description in CIRCUIT_ENTITY_DESCRIPTIONS: - entity = await hass.async_add_executor_job( - _build_entity, - circuit, - device_config, - description, - ) - if entity: - entities.append(entity) - - async_add_entities(entities) + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + api, + device_config, + ) + ) class ViCareNumber(ViCareEntity, NumberEntity): diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index bfad8b107cb..875d8790c52 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -6,8 +6,11 @@ from contextlib import suppress from dataclasses import dataclass import logging -from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -59,7 +62,7 @@ VICARE_UNIT_TO_DEVICE_CLASS = { class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare sensor entity.""" - unit_getter: Callable[[Device], str | None] | None = None + unit_getter: Callable[[PyViCareDevice], str | None] | None = None GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( @@ -628,45 +631,86 @@ async def _entities_from_descriptions( entities.append(entity) +def _build_entities( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create ViCare sensor entities for a device.""" + + entities: list[ViCareSensor] = _build_entities_for_device(device, device_config) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_burners(device), device_config, BURNER_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_compressors(device), device_config, COMPRESSOR_SENSORS + ) + ) + return entities + + +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create device specific ViCare sensor entities.""" + + return [ + ViCareSensor( + device, + device_config, + description, + ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] + + +def _build_entities_for_component( + components: list[PyViCareHeatingDeviceWithComponent], + device_config: PyViCareDeviceConfig, + entity_descriptions: tuple[ViCareSensorEntityDescription, ...], +) -> list[ViCareSensor]: + """Create component specific ViCare sensor entities.""" + + return [ + ViCareSensor( + component, + device_config, + description, + ) + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - api: Device = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + api: PyViCareDevice = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config: PyViCareDeviceConfig = hass.data[DOMAIN][config_entry.entry_id][ VICARE_DEVICE_CONFIG ] - entities = [] - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, device_config, - description, ) - if entity: - entities.append(entity) - - circuits = await hass.async_add_executor_job(get_circuits, api) - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, circuits, config_entry ) - burners = await hass.async_add_executor_job(get_burners, api) - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, burners, config_entry - ) - - compressors = await hass.async_add_executor_job(get_compressors, api) - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, compressors, config_entry - ) - - async_add_entities(entities) - class ViCareSensor(ViCareEntity, SensorEntity): """Representation of a ViCare sensor.""" diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 9b154da2bc2..036ced5ee55 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -1,4 +1,6 @@ """Viessmann ViCare water_heater device.""" +from __future__ import annotations + from contextlib import suppress import logging from typing import Any @@ -58,27 +60,38 @@ HA_TO_VICARE_HVAC_DHW = { } +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareWater]: + """Create ViCare water entities for a device.""" + return [ + ViCareWater( + api, + circuit, + device_config, + "water", + ) + for circuit in get_circuits(api) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - for circuit in circuits: - entity = ViCareWater( + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - circuit, device_config, - "water", ) - entities.append(entity) - - async_add_entities(entities) + ) class ViCareWater(ViCareEntity, WaterHeaterEntity): From 4d00767081717d62f64a7187474aa81d719f2c18 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:35:08 +0100 Subject: [PATCH 838/982] ASUSWRT: add description of host field. Fix title (#104690) Co-authored-by: Franck Nijhof --- homeassistant/components/asuswrt/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index cf105a6a708..8a3207ec7cb 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -2,7 +2,6 @@ "config": { "step": { "user": { - "title": "AsusWRT", "description": "Set required parameter to connect to your router", "data": { "host": "[%key:common::config_flow::data::host%]", @@ -11,10 +10,12 @@ "ssh_key": "Path to your SSH key file (instead of password)", "protocol": "Communication protocol to use", "port": "Port (leave empty for protocol default)" + }, + "data_description": { + "host": "The hostname or IP address of your ASUSWRT router." } }, "legacy": { - "title": "AsusWRT", "description": "Set required parameters to connect to your router", "data": { "mode": "Router operating mode" @@ -37,7 +38,6 @@ "options": { "step": { "init": { - "title": "AsusWRT Options", "data": { "consider_home": "Seconds to wait before considering a device away", "track_unknown": "Track unknown / unnamed devices", From 8c56b5ef826abe7bcc78d3dd6cf9d937a751c649 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 29 Nov 2023 09:35:38 +0100 Subject: [PATCH 839/982] Add a host field description for Bravia, Brother and NAM (#104689) --- homeassistant/components/braviatv/strings.json | 3 +++ homeassistant/components/brother/strings.json | 3 +++ homeassistant/components/nam/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index 8f8e728cb9d..4b28fa91d74 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your TV is turned on before trying to set it up.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Sony Bravia TV to control." } }, "authorize": { diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index e24c941c514..0d8f4f4eedf 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "type": "Type of the printer" + }, + "data_description": { + "host": "The hostname or IP address of the Brother printer to control." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e443a398984..83a40d87f76 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -6,6 +6,9 @@ "description": "Set up Nettigo Air Monitor integration.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Nettigo Air Monitor to control." } }, "credentials": { From 6a878767292f2825c45a175022cd8f594dd98caa Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 29 Nov 2023 09:39:30 +0100 Subject: [PATCH 840/982] Handle server disconnection for Vodafone devices (#104650) --- homeassistant/components/vodafone_station/coordinator.py | 7 ++++--- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index a2cddcf9a65..ff51f009f3c 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -97,6 +97,9 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): try: try: await self.api.login() + raw_data_devices = await self.api.get_devices_data() + data_sensors = await self.api.get_sensor_data() + await self.api.logout() except exceptions.CannotAuthenticate as err: raise ConfigEntryAuthFailed from err except ( @@ -117,10 +120,8 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): dev_info, utc_point_in_time ), ) - for dev_info in (await self.api.get_devices_data()).values() + for dev_info in (raw_data_devices).values() } - data_sensors = await self.api.get_sensor_data() - await self.api.logout() return UpdateCoordinatorDataType(data_devices, data_sensors) @property diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 2a1814c83d0..20ea4db057e 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.2"] + "requirements": ["aiovodafone==0.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index cb7964e7c2f..bce0550bb44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -380,7 +380,7 @@ aiounifi==66 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.2 +aiovodafone==0.4.3 # homeassistant.components.waqi aiowaqi==3.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f646b41e3cc..a58be624388 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aiounifi==66 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.2 +aiovodafone==0.4.3 # homeassistant.components.waqi aiowaqi==3.0.1 From a3bad5458320ee7b5d70bf758ed4e6b471790319 Mon Sep 17 00:00:00 2001 From: Sebastian YEPES Date: Wed, 29 Nov 2023 09:48:45 +0100 Subject: [PATCH 841/982] Add Tuya Smart Water Timer (#95053) Co-authored-by: Franck Nijhof --- homeassistant/components/tuya/const.py | 2 ++ homeassistant/components/tuya/select.py | 10 ++++++++++ homeassistant/components/tuya/sensor.py | 12 ++++++++++++ homeassistant/components/tuya/strings.json | 16 ++++++++++++++++ homeassistant/components/tuya/switch.py | 8 ++++++++ 5 files changed, 48 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index acf9f8bbd2c..19faa76a191 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -338,6 +338,7 @@ class DPCode(StrEnum): TEMP_VALUE_V2 = "temp_value_v2" TEMPER_ALARM = "temper_alarm" # Tamper alarm TIME_TOTAL = "time_total" + TIME_USE = "time_use" # Total seconds of irrigation TOTAL_CLEAN_AREA = "total_clean_area" TOTAL_CLEAN_COUNT = "total_clean_count" TOTAL_CLEAN_TIME = "total_clean_time" @@ -362,6 +363,7 @@ class DPCode(StrEnum): WATER_RESET = "water_reset" # Resetting of water usage days WATER_SET = "water_set" # Water level WATERSENSOR_STATE = "watersensor_state" + WEATHER_DELAY = "weather_delay" WET = "wet" # Humidification WINDOW_CHECK = "window_check" WINDOW_STATE = "window_state" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 3cc8c72f555..bc44ddf479c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -75,6 +75,16 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { icon="mdi:thermometer-lines", ), ), + # Smart Water Timer + "sfkzq": ( + # Irrigation will not be run within this set delay period + SelectEntityDescription( + key=DPCode.WEATHER_DELAY, + translation_key="weather_delay", + icon="mdi:weather-cloudy-clock", + entity_category=EntityCategory.CONFIG, + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 313900fab4e..4bf8808f5f1 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -517,6 +517,18 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Smart Water Timer + "sfkzq": ( + # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) + TuyaSensorEntityDescription( + key=DPCode.TIME_USE, + translation_key="total_watering_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + *BATTERY_SENSORS, + ), # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": BATTERY_SENSORS, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 9c807419551..e9b13e10a95 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -421,6 +421,19 @@ "4": "Mood 4", "5": "Mood 5" } + }, + "weather_delay": { + "name": "Weather delay", + "state": { + "cancel": "Cancel", + "24h": "24h", + "48h": "48h", + "72h": "72h", + "96h": "96h", + "120h": "120h", + "144h": "144h", + "168h": "168h" + } } }, "sensor": { @@ -556,6 +569,9 @@ "water_level": { "name": "Water level" }, + "total_watering_time": { + "name": "Total watering time" + }, "filter_utilization": { "name": "Filter utilization" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index a48d797555c..ba304b4069e 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -430,6 +430,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Water Timer + "sfkzq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + icon="mdi:sprinkler-variant", + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( From 634785a2d85597b0a6b3cac49c887ceb753ecdd6 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 10:02:49 +0100 Subject: [PATCH 842/982] Atag: add host field description (#104691) --- homeassistant/components/atag/strings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/atag/strings.json b/homeassistant/components/atag/strings.json index 39ed972524d..82070c0209f 100644 --- a/homeassistant/components/atag/strings.json +++ b/homeassistant/components/atag/strings.json @@ -2,10 +2,13 @@ "config": { "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Atag device." } } }, From afc3f1d933621cd0c522ce6f2ea5e61bc1ec5af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 29 Nov 2023 11:07:54 +0200 Subject: [PATCH 843/982] Make huawei_lte operator search and preferred network modes translatable (#104673) --- homeassistant/components/huawei_lte/sensor.py | 20 +------------------ .../components/huawei_lte/strings.json | 19 +++++++++++++++--- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index ee7256340df..ca3734bb305 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -8,8 +8,6 @@ from datetime import datetime, timedelta import logging import re -from huawei_lte_api.enums.net import NetworkModeEnum - from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, @@ -575,10 +573,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "State": HuaweiSensorEntityDescription( key="State", translation_key="operator_search_mode", - format_fn=lambda x: ( - {"0": "Auto", "1": "Manual"}.get(x), - None, - ), entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -588,19 +582,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "NetworkMode": HuaweiSensorEntityDescription( key="NetworkMode", - translation_key="preferred_mode", - format_fn=lambda x: ( - { - NetworkModeEnum.MODE_AUTO.value: "4G/3G/2G", - NetworkModeEnum.MODE_4G_3G_AUTO.value: "4G/3G", - NetworkModeEnum.MODE_4G_2G_AUTO.value: "4G/2G", - NetworkModeEnum.MODE_4G_ONLY.value: "4G", - NetworkModeEnum.MODE_3G_2G_AUTO.value: "3G/2G", - NetworkModeEnum.MODE_3G_ONLY.value: "3G", - NetworkModeEnum.MODE_2G_ONLY.value: "2G", - }.get(x), - None, - ), + translation_key="preferred_network_mode", entity_category=EntityCategory.DIAGNOSTIC, ), }, diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 9e46ca742b8..754f192e57e 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -231,10 +231,23 @@ "name": "Operator code" }, "operator_search_mode": { - "name": "Operator search mode" + "name": "Operator search mode", + "state": { + "0": "Auto", + "1": "Manual" + } }, - "preferred_mode": { - "name": "Preferred mode" + "preferred_network_mode": { + "name": "Preferred network mode", + "state": { + "00": "4G/3G/2G auto", + "0302": "4G/3G auto", + "0301": "4G/2G auto", + "03": "4G only", + "0201": "3G/2G auto", + "02": "3G only", + "01": "2G only" + } }, "sms_deleted_device": { "name": "SMS deleted (device)" From efd330f18222c463b9a61babbc7a4c58be7128c1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 10:47:23 +0100 Subject: [PATCH 844/982] Send localization info on websocket_api script errors (#104638) * Send localization info on script errors * Use connection exception hander * Keep HomeAssistantError is unknown_error * Move specific exception handling --- .../components/websocket_api/__init__.py | 1 + .../components/websocket_api/commands.py | 20 ++++++- .../components/websocket_api/connection.py | 20 ++++++- tests/common.py | 3 + .../components/websocket_api/test_commands.py | 59 +++++++++++++++++++ .../websocket_api/test_connection.py | 6 ++ 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 9c2645aec57..f7086cc81db 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -17,6 +17,7 @@ from .const import ( # noqa: F401 ERR_INVALID_FORMAT, ERR_NOT_FOUND, ERR_NOT_SUPPORTED, + ERR_SERVICE_VALIDATION_ERROR, ERR_TEMPLATE_ERROR, ERR_TIMEOUT, ERR_UNAUTHORIZED, diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 18688914e8b..5edf5018938 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -778,7 +778,25 @@ async def handle_execute_script( context = connection.context(msg) script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN) - script_result = await script_obj.async_run(msg.get("variables"), context=context) + try: + script_result = await script_obj.async_run( + msg.get("variables"), context=context + ) + except ServiceValidationError as err: + connection.logger.error(err) + connection.logger.debug("", exc_info=err) + connection.send_error( + msg["id"], + const.ERR_SERVICE_VALIDATION_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return + except Exception as exc: # pylint: disable=broad-except + connection.async_handle_exception(msg, exc) + return connection.send_result( msg["id"], { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 4581b3be773..551d4b0d1fe 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -255,7 +255,10 @@ class ActiveConnection: log_handler = self.logger.error code = const.ERR_UNKNOWN_ERROR - err_message = None + err_message: str | None = None + translation_domain: str | None = None + translation_key: str | None = None + translation_placeholders: dict[str, Any] | None = None if isinstance(err, Unauthorized): code = const.ERR_UNAUTHORIZED @@ -268,6 +271,10 @@ class ActiveConnection: err_message = "Timeout" elif isinstance(err, HomeAssistantError): err_message = str(err) + code = const.ERR_UNKNOWN_ERROR + translation_domain = err.translation_domain + translation_key = err.translation_key + translation_placeholders = err.translation_placeholders # This if-check matches all other errors but also matches errors which # result in an empty message. In that case we will also log the stack @@ -276,7 +283,16 @@ class ActiveConnection: err_message = "Unknown error" log_handler = self.logger.exception - self.send_message(messages.error_message(msg["id"], code, err_message)) + self.send_message( + messages.error_message( + msg["id"], + code, + err_message, + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + ) if code: err_message += f" ({code})" diff --git a/tests/common.py b/tests/common.py index a4979c85853..b2fa53d28fb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -297,6 +297,7 @@ def async_mock_service( schema: vol.Schema | None = None, response: ServiceResponse = None, supports_response: SupportsResponse | None = None, + raise_exception: Exception | None = None, ) -> list[ServiceCall]: """Set up a fake service & return a calls log list to this service.""" calls = [] @@ -305,6 +306,8 @@ def async_mock_service( def mock_service_log(call): # pylint: disable=unnecessary-lambda """Mock service call.""" calls.append(call) + if raise_exception is not None: + raise raise_exception return response if supports_response is None: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index a9551310c2a..c573fb85bb1 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2317,6 +2317,65 @@ async def test_execute_script( assert call.context.as_dict() == msg_var["result"]["context"] +@pytest.mark.parametrize( + ("raise_exception", "err_code"), + [ + ( + HomeAssistantError( + "Some error", + translation_domain="test", + translation_key="test_error", + translation_placeholders={"option": "bla"}, + ), + "unknown_error", + ), + ( + ServiceValidationError( + "Some error", + translation_domain="test", + translation_key="test_error", + translation_placeholders={"option": "bla"}, + ), + "service_validation_error", + ), + ], +) +async def test_execute_script_err_localization( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + raise_exception: HomeAssistantError, + err_code: str, +) -> None: + """Test testing a condition.""" + async_mock_service( + hass, "domain_test", "test_service", raise_exception=raise_exception + ) + + await websocket_client.send_json( + { + "id": 5, + "type": "execute_script", + "sequence": [ + { + "service": "domain_test.test_service", + "data": {"hello": "world"}, + }, + {"stop": "done", "response_variable": "service_result"}, + ], + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == err_code + assert msg["error"]["message"] == "Some error" + assert msg["error"]["translation_key"] == "test_error" + assert msg["error"]["translation_domain"] == "test" + assert msg["error"]["translation_placeholders"] == {"option": "bla"} + + async def test_execute_script_complex_response( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index da435d64d58..4bc736481c4 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -43,6 +43,12 @@ from tests.common import MockUser "Failed to do X", "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", ), + ( + exceptions.ServiceValidationError("Failed to do X"), + websocket_api.ERR_UNKNOWN_ERROR, + "Failed to do X", + "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", + ), ( ValueError("Really bad"), websocket_api.ERR_UNKNOWN_ERROR, From bcfb5307f5865e15dc81ebf969acab2a4de5e33e Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 11:06:50 +0100 Subject: [PATCH 845/982] Balboa, Bond, Bosch: add host field description (#104695) --- homeassistant/components/balboa/strings.json | 5 ++++- homeassistant/components/bond/strings.json | 3 +++ homeassistant/components/bosch_shc/strings.json | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 238deb7d65d..101436c0f31 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -2,9 +2,12 @@ "config": { "step": { "user": { - "title": "Connect to the Balboa Wi-Fi device", + "description": "Connect to the Balboa Wi-Fi device", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58." } } }, diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index 4c7c224bc44..8986905c6ee 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -12,6 +12,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "The IP address of your Bond hub." } } }, diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 90688e1373f..88eb817bbd9 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -6,6 +6,9 @@ "title": "SHC authentication parameters", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bosch Smart Home Controller." } }, "credentials": { From 999875d0e45c7b1414b78b0d0939bf0631abf6d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Nov 2023 11:26:50 +0100 Subject: [PATCH 846/982] Autogenerate Dockerfile (#104669) --- Dockerfile | 3 ++ script/hassfest/__main__.py | 2 + script/hassfest/docker.py | 89 +++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 script/hassfest/docker.py diff --git a/Dockerfile b/Dockerfile index b61e1461c52..97eeb5b0dfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,6 @@ +# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker ARG BUILD_FROM FROM ${BUILD_FROM} diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 32803731ecd..c454c69d141 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -16,6 +16,7 @@ from . import ( coverage, dependencies, dhcp, + docker, json, manifest, metadata, @@ -50,6 +51,7 @@ INTEGRATION_PLUGINS = [ ] HASS_PLUGINS = [ coverage, + docker, mypy_config, metadata, ] diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py new file mode 100644 index 00000000000..1849e8e7ec8 --- /dev/null +++ b/script/hassfest/docker.py @@ -0,0 +1,89 @@ +"""Generate and validate the dockerfile.""" +from homeassistant import core +from homeassistant.util import executor, thread + +from .model import Config, Integration + +DOCKERFILE_TEMPLATE = """# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker +ARG BUILD_FROM +FROM ${{BUILD_FROM}} + +# Synchronize with homeassistant/core.py:async_stop +ENV \\ + S6_SERVICES_GRACETIME={timeout} + +ARG QEMU_CPU + +WORKDIR /usr/src + +## Setup Home Assistant Core dependencies +COPY requirements.txt homeassistant/ +COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ +RUN \\ + pip3 install \\ + --only-binary=:all: \\ + -r homeassistant/requirements.txt + +COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ +RUN \\ + if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \\ + pip3 install homeassistant/home_assistant_frontend-*.whl; \\ + fi \\ + && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \\ + pip3 install homeassistant/home_assistant_intents-*.whl; \\ + fi \\ + && \\ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \\ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \\ + pip3 install \\ + --only-binary=:all: \\ + -r homeassistant/requirements_all.txt + +## Setup Home Assistant Core +COPY . homeassistant/ +RUN \\ + pip3 install \\ + --only-binary=:all: \\ + -e ./homeassistant \\ + && python3 -m compileall \\ + homeassistant/homeassistant + +# Home Assistant S6-Overlay +COPY rootfs / + +WORKDIR /config +""" + + +def _generate_dockerfile() -> str: + timeout = ( + core.STAGE_1_SHUTDOWN_TIMEOUT + + core.STAGE_2_SHUTDOWN_TIMEOUT + + core.STAGE_3_SHUTDOWN_TIMEOUT + + executor.EXECUTOR_SHUTDOWN_TIMEOUT + + thread.THREADING_SHUTDOWN_TIMEOUT + + 10 + ) + return DOCKERFILE_TEMPLATE.format(timeout=timeout * 1000) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate dockerfile.""" + dockerfile_content = _generate_dockerfile() + config.cache["dockerfile"] = dockerfile_content + + dockerfile_path = config.root / "Dockerfile" + if dockerfile_path.read_text() != dockerfile_content: + config.add_error( + "docker", + "File Dockerfile is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate dockerfile.""" + dockerfile_path = config.root / "Dockerfile" + dockerfile_path.write_text(config.cache["dockerfile"]) From bd8f01bd35d95789b49769dbc40dc51870f8a339 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Nov 2023 05:30:15 -0500 Subject: [PATCH 847/982] Bump ZHA dependencies (#104335) --- homeassistant/components/zha/__init__.py | 106 ++++++++++---- homeassistant/components/zha/config_flow.py | 26 ++-- homeassistant/components/zha/core/const.py | 2 +- homeassistant/components/zha/core/device.py | 2 +- homeassistant/components/zha/core/gateway.py | 96 ++++++------- homeassistant/components/zha/entity.py | 2 +- homeassistant/components/zha/manifest.json | 14 +- homeassistant/components/zha/radio_manager.py | 20 ++- requirements_all.txt | 14 +- requirements_test_all.txt | 14 +- .../test_silabs_multiprotocol_addon.py | 13 +- .../test_config_flow.py | 4 +- .../homeassistant_sky_connect/test_init.py | 8 +- .../homeassistant_yellow/test_config_flow.py | 4 +- .../homeassistant_yellow/test_init.py | 4 +- tests/components/zha/conftest.py | 6 +- tests/components/zha/test_config_flow.py | 97 +++++++------ tests/components/zha/test_gateway.py | 136 +++--------------- tests/components/zha/test_init.py | 79 ++++++++-- tests/components/zha/test_repairs.py | 6 + tests/components/zha/test_websocket_api.py | 18 ++- 21 files changed, 349 insertions(+), 322 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 222c7f1d4ef..2046070d6a5 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -9,12 +9,12 @@ import re import voluptuous as vol from zhaquirks import setup as setup_quirks from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH -from zigpy.exceptions import NetworkSettingsInconsistent +from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -29,6 +29,7 @@ from .core.const import ( CONF_CUSTOM_QUIRKS_PATH, CONF_DEVICE_CONFIG, CONF_ENABLE_QUIRKS, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, CONF_ZIGPY, @@ -36,6 +37,8 @@ from .core.const import ( DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, + STARTUP_FAILURE_DELAY_S, + STARTUP_RETRIES, RadioType, ) from .core.device import get_device_automation_triggers @@ -158,42 +161,67 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) + # Retry setup a few times before giving up to deal with missing serial ports in VMs + for attempt in range(STARTUP_RETRIES): + try: + zha_gateway = await ZHAGateway.async_from_config( + hass=hass, + config=zha_data.yaml_config, + config_entry=config_entry, + ) + break + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise ConfigEntryError( + "Network settings do not match most recent backup" + ) from exc + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug( + "Couldn't start coordinator (attempt %s of %s)", + attempt + 1, + STARTUP_RETRIES, + exc_info=exc, + ) - try: - await zha_gateway.async_initialize() - except NetworkSettingsInconsistent as exc: - await warn_on_inconsistent_network_settings( - hass, - config_entry=config_entry, - old_state=exc.old_state, - new_state=exc.new_state, - ) - raise HomeAssistantError( - "Network settings do not match most recent backup" - ) from exc - except Exception: - if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: - try: - await warn_on_wrong_silabs_firmware( - hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - ) - except AlreadyRunningEZSP as exc: - # If connecting fails but we somehow probe EZSP (e.g. stuck in the - # bootloader), reconnect, it should work - raise ConfigEntryNotReady from exc + if attempt < STARTUP_RETRIES - 1: + await asyncio.sleep(STARTUP_FAILURE_DELAY_S) + continue - raise + if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: + try: + # Ignore all exceptions during probing, they shouldn't halt setup + await warn_on_wrong_silabs_firmware( + hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + ) + except AlreadyRunningEZSP as ezsp_exc: + raise ConfigEntryNotReady from ezsp_exc + + raise repairs.async_delete_blocking_issues(hass) + manufacturer = zha_gateway.state.node_info.manufacturer + model = zha_gateway.state.node_info.model + + if manufacturer is None and model is None: + manufacturer = "Unknown" + model = "Unknown" + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))}, - identifiers={(DOMAIN, str(zha_gateway.coordinator_ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.state.node_info.ieee))}, + identifiers={(DOMAIN, str(zha_gateway.state.node_info.ieee))}, name="Zigbee Coordinator", - manufacturer="ZHA", - model=zha_gateway.radio_description, + manufacturer=manufacturer, + model=model, + sw_version=zha_gateway.state.node_info.version, ) websocket_api.async_load_api(hass) @@ -267,5 +295,23 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.version = 3 hass.config_entries.async_update_entry(config_entry, data=data) + if config_entry.version == 3: + data = {**config_entry.data} + + if not data[CONF_DEVICE].get(CONF_BAUDRATE): + data[CONF_DEVICE][CONF_BAUDRATE] = { + "deconz": 38400, + "xbee": 57600, + "ezsp": 57600, + "znp": 115200, + "zigate": 115200, + }[data[CONF_RADIO_TYPE]] + + if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL): + data[CONF_DEVICE][CONF_FLOW_CONTROL] = None + + config_entry.version = 4 + hass.config_entries.async_update_entry(config_entry, data=data) + _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1b6bbee5159..60cf917d9f6 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -27,12 +27,13 @@ from homeassistant.util import dt as dt_util from .core.const import ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN, RadioType, ) from .radio_manager import ( + DEVICE_SCHEMA, HARDWARE_DISCOVERY_SCHEMA, RECOMMENDED_RADIOS, ProbeResult, @@ -42,7 +43,7 @@ from .radio_manager import ( CONF_MANUAL_PATH = "Enter Manually" SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, ) DECONZ_DOMAIN = "deconz" @@ -160,7 +161,7 @@ class BaseZhaFlow(FlowHandler): return self.async_create_entry( title=self._title, data={ - CONF_DEVICE: device_settings, + CONF_DEVICE: DEVICE_SCHEMA(device_settings), CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, }, ) @@ -281,7 +282,7 @@ class BaseZhaFlow(FlowHandler): for ( param, value, - ) in self._radio_mgr.radio_type.controller.SCHEMA_DEVICE.schema.items(): + ) in DEVICE_SCHEMA.schema.items(): if param not in SUPPORTED_PORT_SETTINGS: continue @@ -488,7 +489,7 @@ class BaseZhaFlow(FlowHandler): class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 3 + VERSION = 4 async def _set_unique_id_or_update_path( self, unique_id: str, device_path: str @@ -646,22 +647,17 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN name = discovery_data["name"] radio_type = self._radio_mgr.parse_radio_type(discovery_data["radio_type"]) - - try: - device_settings = radio_type.controller.SCHEMA_DEVICE( - discovery_data["port"] - ) - except vol.Invalid: - return self.async_abort(reason="invalid_hardware_data") + device_settings = discovery_data["port"] + device_path = device_settings[CONF_DEVICE_PATH] await self._set_unique_id_or_update_path( - unique_id=f"{name}_{radio_type.name}_{device_settings[CONF_DEVICE_PATH]}", - device_path=device_settings[CONF_DEVICE_PATH], + unique_id=f"{name}_{radio_type.name}_{device_path}", + device_path=device_path, ) self._title = name self._radio_mgr.radio_type = radio_type - self._radio_mgr.device_path = device_settings[CONF_DEVICE_PATH] + self._radio_mgr.device_path = device_path self._radio_mgr.device_settings = device_settings self.context["title_placeholders"] = {CONF_NAME: name} diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9874fddc598..f89ed8d9a52 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -127,6 +127,7 @@ CONF_ALARM_FAILED_TRIES = "alarm_failed_tries" CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code" CONF_BAUDRATE = "baudrate" +CONF_FLOW_CONTROL = "flow_control" CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path" CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition" CONF_DEVICE_CONFIG = "device_config" @@ -136,7 +137,6 @@ CONF_ALWAYS_PREFER_XY_COLOR_MODE = "always_prefer_xy_color_mode" CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" -CONF_FLOWCONTROL = "flow_control" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 44acbb172fc..0ce6f47b61e 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -285,7 +285,7 @@ class ZHADevice(LogMixin): if not self.is_coordinator: return False - return self.ieee == self.gateway.coordinator_ieee + return self.ieee == self.gateway.state.node_info.ieee @property def is_end_device(self) -> bool | None: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index b4c02d33015..5c038a2d7f8 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -11,7 +11,7 @@ import itertools import logging import re import time -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, Self from zigpy.application import ControllerApplication from zigpy.config import ( @@ -24,15 +24,14 @@ from zigpy.config import ( ) import zigpy.device import zigpy.endpoint -from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError import zigpy.group +from zigpy.state import State from zigpy.types.named import EUI64 from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components.system_log import LogEntry, _figure_out_source from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -66,8 +65,6 @@ from .const import ( SIGNAL_ADD_ENTITIES, SIGNAL_GROUP_MEMBERSHIP_CHANGE, SIGNAL_REMOVE, - STARTUP_FAILURE_DELAY_S, - STARTUP_RETRIES, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, ZHA_GW_MSG, @@ -123,10 +120,6 @@ class DevicePairingStatus(Enum): class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" - # -- Set in async_initialize -- - application_controller: ControllerApplication - radio_description: str - def __init__( self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry ) -> None: @@ -135,7 +128,8 @@ class ZHAGateway: self._config = config self._devices: dict[EUI64, ZHADevice] = {} self._groups: dict[int, ZHAGroup] = {} - self.coordinator_zha_device: ZHADevice | None = None + self.application_controller: ControllerApplication = None + self.coordinator_zha_device: ZHADevice = None # type: ignore[assignment] self._device_registry: collections.defaultdict[ EUI64, list[EntityReference] ] = collections.defaultdict(list) @@ -147,13 +141,11 @@ class ZHAGateway: self._log_relay_handler = LogRelayHandler(hass, self) self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] + self.shutting_down = False def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" - radio_type = self.config_entry.data[CONF_RADIO_TYPE] - - app_controller_cls = RadioType[radio_type].controller - self.radio_description = RadioType[radio_type].description + radio_type = RadioType[self.config_entry.data[CONF_RADIO_TYPE]] app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( @@ -170,7 +162,7 @@ class ZHAGateway: # event loop, when a connection to a TCP coordinator fails in a specific way if ( CONF_USE_THREAD not in app_config - and RadioType[radio_type] is RadioType.ezsp + and radio_type is RadioType.ezsp and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") ): app_config[CONF_USE_THREAD] = False @@ -189,48 +181,40 @@ class ZHAGateway: ): app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 - return app_controller_cls, app_controller_cls.SCHEMA(app_config) + return radio_type.controller, radio_type.controller.SCHEMA(app_config) + + @classmethod + async def async_from_config( + cls, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry + ) -> Self: + """Create an instance of a gateway from config objects.""" + instance = cls(hass, config, config_entry) + await instance.async_initialize() + return instance async def async_initialize(self) -> None: """Initialize controller and connect radio.""" discovery.PROBE.initialize(self.hass) discovery.GROUP_PROBE.initialize(self.hass) + self.shutting_down = False + app_controller_cls, app_config = self.get_application_controller_data() - self.application_controller = await app_controller_cls.new( + app = await app_controller_cls.new( config=app_config, auto_form=False, start_radio=False, ) try: - for attempt in range(STARTUP_RETRIES): - try: - await self.application_controller.startup(auto_form=True) - except TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except NetworkSettingsInconsistent: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.debug( - "Couldn't start %s coordinator (attempt %s of %s)", - self.radio_description, - attempt + 1, - STARTUP_RETRIES, - exc_info=exc, - ) - - if attempt == STARTUP_RETRIES - 1: - raise exc - - await asyncio.sleep(STARTUP_FAILURE_DELAY_S) - else: - break + await app.startup(auto_form=True) except Exception: # Explicitly shut down the controller application on failure - await self.application_controller.shutdown() + await app.shutdown() raise + self.application_controller = app + zha_data = get_zha_data(self.hass) zha_data.gateway = self @@ -244,6 +228,17 @@ class ZHAGateway: self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) + def connection_lost(self, exc: Exception) -> None: + """Handle connection lost event.""" + if self.shutting_down: + return + + _LOGGER.debug("Connection to the radio was lost: %r", exc) + + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + def _find_coordinator_device(self) -> zigpy.device.Device: zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) @@ -258,6 +253,7 @@ class ZHAGateway: @callback def async_load_devices(self) -> None: """Restore ZHA devices from zigpy application state.""" + for zigpy_device in self.application_controller.devices.values(): zha_device = self._async_get_or_create_device(zigpy_device, restored=True) delta_msg = "not known" @@ -280,6 +276,7 @@ class ZHAGateway: @callback def async_load_groups(self) -> None: """Initialize ZHA groups.""" + for group_id in self.application_controller.groups: group = self.application_controller.groups[group_id] zha_group = self._async_get_or_create_group(group) @@ -521,9 +518,9 @@ class ZHAGateway: entity_registry.async_remove(entry.entity_id) @property - def coordinator_ieee(self) -> EUI64: - """Return the active coordinator's IEEE address.""" - return self.application_controller.state.node_info.ieee + def state(self) -> State: + """Return the active coordinator's network state.""" + return self.application_controller.state @property def devices(self) -> dict[EUI64, ZHADevice]: @@ -711,6 +708,7 @@ class ZHAGateway: group_id: int | None = None, ) -> ZHAGroup | None: """Create a new Zigpy Zigbee group.""" + # we start with two to fill any gaps from a user removing existing groups if group_id is None: @@ -758,19 +756,13 @@ class ZHAGateway: async def shutdown(self) -> None: """Stop ZHA Controller Application.""" _LOGGER.debug("Shutting down ZHA ControllerApplication") + self.shutting_down = True + for unsubscribe in self._unsubs: unsubscribe() for device in self.devices.values(): device.async_cleanup_handles() - # shutdown is called when the config entry unloads are processed - # there are cases where unloads are processed because of a failure of - # some sort and the application controller may not have been - # created yet - if ( - hasattr(self, "application_controller") - and self.application_controller is not None - ): - await self.application_controller.shutdown() + await self.application_controller.shutdown() def handle_message( self, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 05e1da7c570..b92d077907f 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -92,7 +92,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.coordinator_ieee), + via_device=(DOMAIN, zha_gateway.state.node_info.ieee), ) @callback diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 786caf1809c..cd53772777a 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,16 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.8", + "bellows==0.37.1", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.107", - "zigpy-deconz==0.21.1", - "zigpy==0.59.0", - "zigpy-xbee==0.19.0", - "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.6", - "universal-silabs-flasher==0.0.14", + "zigpy-deconz==0.22.0", + "zigpy==0.60.0", + "zigpy-xbee==0.20.0", + "zigpy-zigate==0.12.0", + "zigpy-znp==0.12.0", + "universal-silabs-flasher==0.0.15", "pyserial-asyncio-fast==0.11" ], "usb": [ diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index d20cf752a91..d3ca03de8d8 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -19,6 +19,7 @@ from zigpy.config import ( CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED, + SCHEMA_DEVICE, ) from zigpy.exceptions import NetworkNotFormed @@ -58,10 +59,21 @@ RETRY_DELAY_S = 1.0 BACKUP_RETRIES = 5 MIGRATION_RETRIES = 100 + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required("path"): str, + vol.Optional("baudrate", default=115200): int, + vol.Optional("flow_control", default=None): vol.In( + ["hardware", "software", None] + ), + } +) + HARDWARE_DISCOVERY_SCHEMA = vol.Schema( { vol.Required("name"): str, - vol.Required("port"): dict, + vol.Required("port"): DEVICE_SCHEMA, vol.Required("radio_type"): str, } ) @@ -204,9 +216,7 @@ class ZhaRadioManager: for radio in AUTOPROBE_RADIOS: _LOGGER.debug("Attempting to probe radio type %s", radio) - dev_config = radio.controller.SCHEMA_DEVICE( - {CONF_DEVICE_PATH: self.device_path} - ) + dev_config = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) probe_result = await radio.controller.probe(dev_config) if not probe_result: @@ -357,7 +367,7 @@ class ZhaMultiPANMigrationHelper: migration_data["new_discovery_info"]["radio_type"] ) - new_device_settings = new_radio_type.controller.SCHEMA_DEVICE( + new_device_settings = SCHEMA_DEVICE( migration_data["new_discovery_info"]["port"] ) diff --git a/requirements_all.txt b/requirements_all.txt index bce0550bb44..756de3bd306 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -523,7 +523,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.1 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.3 @@ -2660,7 +2660,7 @@ unifi-discovery==1.1.7 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.14 +universal-silabs-flasher==0.0.15 # homeassistant.components.upb upb-lib==0.5.4 @@ -2828,19 +2828,19 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.0 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.0 # homeassistant.components.zha -zigpy-zigate==0.11.0 +zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.11.6 +zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a58be624388..bbaaf1bcb16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -445,7 +445,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.1 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.3 @@ -1979,7 +1979,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.7 # homeassistant.components.zha -universal-silabs-flasher==0.0.14 +universal-silabs-flasher==0.0.15 # homeassistant.components.upb upb-lib==0.5.4 @@ -2117,19 +2117,19 @@ zeversolar==0.3.1 zha-quirks==0.0.107 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.0 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.0 # homeassistant.components.zha -zigpy-zigate==0.11.0 +zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.11.6 +zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.0 # homeassistant.components.zwave_js zwave-js-server-python==0.54.0 diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index fbc77cdee9e..f58d561bfb3 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -293,7 +293,14 @@ async def test_option_flow_install_multi_pan_addon_zha( config_entry.add_to_hass(hass) zha_config_entry = MockConfigEntry( - data={"device": {"path": "/dev/ttyTEST123"}, "radio_type": "ezsp"}, + data={ + "device": { + "path": "/dev/ttyTEST123", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, domain=ZHA_DOMAIN, options={}, title="Test", @@ -348,8 +355,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 4d43d29463a..65636b27a16 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -337,8 +337,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index e00603dc8f7..11961c09a2d 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -147,7 +147,7 @@ async def test_setup_zha( assert config_entry.data == { "device": { "baudrate": 115200, - "flow_control": "software", + "flow_control": None, "path": CONFIG_ENTRY_DATA["device"], }, "radio_type": "ezsp", @@ -200,8 +200,8 @@ async def test_setup_zha_multipan( config_entry = hass.config_entries.async_entries("zha")[0] assert config_entry.data == { "device": { - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, "path": "socket://core-silabs-multiprotocol:9999", }, "radio_type": "ezsp", @@ -255,7 +255,7 @@ async def test_setup_zha_multipan_other_device( assert config_entry.data == { "device": { "baudrate": 115200, - "flow_control": "software", + "flow_control": None, "path": CONFIG_ENTRY_DATA["device"], }, "radio_type": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 58d47c41987..242b316de66 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -249,8 +249,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index addc519c865..f8cdcd8a13b 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -145,8 +145,8 @@ async def test_setup_zha_multipan( config_entry = hass.config_entries.async_entries("zha")[0] assert config_entry.data == { "device": { - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, "path": "socket://core-silabs-multiprotocol:9999", }, "radio_type": "ezsp", diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index a4ff5a3b205..1b3a536007a 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -46,7 +46,7 @@ def disable_request_retry_delay(): with patch( "homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR", zigpy.util.retryable_request(tries=3, delay=0), - ): + ), patch("homeassistant.components.zha.STARTUP_FAILURE_DELAY_S", 0.01): yield @@ -83,8 +83,8 @@ class _FakeApp(ControllerApplication): async def permit_ncp(self, time_s: int = 60): pass - async def permit_with_key( - self, node: zigpy.types.EUI64, code: bytes, time_s: int = 60 + async def permit_with_link_key( + self, node: zigpy.types.EUI64, link_key: zigpy.types.KeyData, time_s: int = 60 ): pass diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 9ec8048ea03..883df4aba94 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -10,7 +10,7 @@ import pytest import serial.tools.list_ports from zigpy.backups import BackupManager import zigpy.config -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, SCHEMA_DEVICE import zigpy.device from zigpy.exceptions import NetworkNotFormed import zigpy.types @@ -22,7 +22,7 @@ from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_ from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN, EZSP_OVERWRITE_EUI64, @@ -118,9 +118,7 @@ def mock_detect_radio_type( async def detect(self): self.radio_type = radio_type - self.device_settings = radio_type.controller.SCHEMA_DEVICE( - {CONF_DEVICE_PATH: self.device_path} - ) + self.device_settings = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) return ret @@ -181,7 +179,7 @@ async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: assert result3["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, CONF_DEVICE_PATH: "socket://192.168.1.200:6638", }, CONF_RADIO_TYPE: "znp", @@ -238,6 +236,8 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non assert result4["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "zigate", } @@ -287,7 +287,7 @@ async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "ezsp", } @@ -304,7 +304,7 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.5:6638", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } }, ) @@ -328,7 +328,7 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE] == { CONF_DEVICE_PATH: "socket://192.168.1.22:6638", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } @@ -483,6 +483,8 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None assert result4["data"] == { "device": { "path": "/dev/ttyZIGBEE", + "baudrate": 115200, + "flow_control": None, }, CONF_RADIO_TYPE: "zigate", } @@ -555,7 +557,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB1", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } }, ) @@ -579,7 +581,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE] == { CONF_DEVICE_PATH: "/dev/ttyZIGBEE", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } @@ -754,6 +756,8 @@ async def test_user_flow(hass: HomeAssistant) -> None: assert result2["data"] == { "device": { "path": port.device, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "deconz", } @@ -773,7 +777,11 @@ async def test_user_flow_not_detected(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, - data={zigpy.config.CONF_DEVICE_PATH: port_select}, + data={ + zigpy.config.CONF_DEVICE_PATH: port_select, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, ) assert result["type"] == FlowResultType.FORM @@ -951,31 +959,6 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: assert probe_mock.await_count == 1 -@pytest.mark.parametrize( - ("old_type", "new_type"), - [ - ("ezsp", "ezsp"), - ("ti_cc", "znp"), # only one that should change - ("znp", "znp"), - ("deconz", "deconz"), - ], -) -async def test_migration_ti_cc_to_znp( - old_type, new_type, hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test zigpy-cc to zigpy-znp config migration.""" - config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} - config_entry.version = 2 - config_entry.add_to_hass(hass) - - with patch("homeassistant.components.zha.async_setup_entry", return_value=True): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.version > 2 - assert config_entry.data[CONF_RADIO_TYPE] == new_type - - @pytest.mark.parametrize("onboarded", [True, False]) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_hardware(onboarded, hass: HomeAssistant) -> None: @@ -1022,7 +1005,7 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: assert result3["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: "hardware", + CONF_FLOW_CONTROL: "hardware", CONF_DEVICE_PATH: "/dev/ttyAMA1", }, CONF_RADIO_TYPE: "ezsp", @@ -1171,6 +1154,7 @@ async def test_formation_strategy_form_initial_network( @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_onboarding_auto_formation_new_hardware( mock_app, hass: HomeAssistant ) -> None: @@ -1577,7 +1561,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB0", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1645,7 +1629,7 @@ async def test_options_flow_defaults( # Change everything CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: "software", }, ) @@ -1668,7 +1652,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: "software", }, CONF_RADIO_TYPE: "znp", } @@ -1697,7 +1681,7 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://localhost:5678", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1766,7 +1750,7 @@ async def test_options_flow_restarts_running_zha_if_cancelled( CONF_DEVICE: { CONF_DEVICE_PATH: "socket://localhost:5678", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1821,7 +1805,7 @@ async def test_options_flow_migration_reset_old_adapter( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/serial/by-id/old_radio", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1954,3 +1938,28 @@ async def test_discovery_wrong_firmware_installed(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_firmware_installed" + + +@pytest.mark.parametrize( + ("old_type", "new_type"), + [ + ("ezsp", "ezsp"), + ("ti_cc", "znp"), # only one that should change + ("znp", "znp"), + ("deconz", "deconz"), + ], +) +async def test_migration_ti_cc_to_znp( + old_type: str, new_type: str, hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test zigpy-cc to zigpy-znp config migration.""" + config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} + config_entry.version = 2 + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.zha.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version > 2 + assert config_entry.data[CONF_RADIO_TYPE] == new_type diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 2a0a241c864..4f520920704 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -4,22 +4,21 @@ from unittest.mock import MagicMock, patch import pytest from zigpy.application import ControllerApplication -import zigpy.exceptions import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting -from homeassistant.components.zha.core.const import RadioType -from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .common import async_find_group_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import MockConfigEntry + IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" @@ -224,101 +223,6 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", - MagicMock(), -) -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", - MagicMock(), -) -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -@pytest.mark.parametrize( - "startup_effect", - [ - [asyncio.TimeoutError(), FileNotFoundError(), None], - [asyncio.TimeoutError(), None], - [None], - ], -) -async def test_gateway_initialize_success( - startup_effect: list[Exception | None], - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA initializing the gateway successfully.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = startup_effect - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ): - await zha_gateway.async_initialize() - - assert zigpy_app_controller.startup.call_count == len(startup_effect) - device_light_1.async_cleanup_handles() - - -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -async def test_gateway_initialize_failure( - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA failing to initialize the gateway.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = [ - asyncio.TimeoutError(), - RuntimeError(), - FileNotFoundError(), - ] - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ), pytest.raises(FileNotFoundError): - await zha_gateway.async_initialize() - - assert zigpy_app_controller.startup.call_count == 3 - - -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -async def test_gateway_initialize_failure_transient( - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA failing to initialize the gateway but with a transient error.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = [ - RuntimeError(), - zigpy.exceptions.TransientConnectionError(), - ] - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ), pytest.raises(ConfigEntryNotReady): - await zha_gateway.async_initialize() - - # Initialization immediately stops and is retried after TransientConnectionError - assert zigpy_app_controller.startup.call_count == 2 - - @patch( "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", MagicMock(), @@ -340,22 +244,25 @@ async def test_gateway_initialize_bellows_thread( thread_state: bool, config_override: dict, hass: HomeAssistant, - coordinator: ZHADevice, zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) - zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) - zha_gateway.config_entry.data["device"]["path"] = device_path - zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) - await zha_gateway.async_initialize() + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + await zha_gateway.async_initialize() - RadioType.ezsp.controller.new.mock_calls[-1].kwargs["config"][ - "use_thread" - ] is thread_state + mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state + + await zha_gateway.shutdown() @pytest.mark.parametrize( @@ -373,15 +280,14 @@ async def test_gateway_force_multi_pan_channel( config_override: dict, expected_channel: int | None, hass: HomeAssistant, - coordinator, + config_entry: MockConfigEntry, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) - zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) - zha_gateway.config_entry.data["device"]["path"] = device_path - zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) _, config = zha_gateway.get_application_controller_data() assert config["network"]["channel"] == expected_channel diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index ad6ab4e351e..c2e9469c239 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,5 +1,6 @@ """Tests for ZHA integration init.""" import asyncio +import typing from unittest.mock import AsyncMock, Mock, patch import pytest @@ -9,6 +10,7 @@ from zigpy.exceptions import TransientConnectionError from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, DOMAIN, @@ -61,9 +63,8 @@ async def test_migration_from_v1_no_baudrate( assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert CONF_DEVICE in config_entry_v1.data assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH - assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] assert CONF_USB_PATH not in config_entry_v1.data - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -80,7 +81,7 @@ async def test_migration_from_v1_with_baudrate( assert CONF_USB_PATH not in config_entry_v1.data assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE] assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200 - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -95,8 +96,7 @@ async def test_migration_from_v1_wrong_baudrate( assert CONF_DEVICE in config_entry_v1.data assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH assert CONF_USB_PATH not in config_entry_v1.data - assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @pytest.mark.skipif( @@ -149,23 +149,74 @@ async def test_setup_with_v3_cleaning_uri( mock_zigpy_connect: ControllerApplication, ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" - config_entry_v3 = MockConfigEntry( + config_entry_v4 = MockConfigEntry( domain=DOMAIN, data={ CONF_RADIO_TYPE: DATA_RADIO_TYPE, - CONF_DEVICE: {CONF_DEVICE_PATH: path, CONF_BAUDRATE: 115200}, + CONF_DEVICE: { + CONF_DEVICE_PATH: path, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, }, - version=3, + version=4, ) - config_entry_v3.add_to_hass(hass) + config_entry_v4.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_v3.entry_id) + await hass.config_entries.async_setup(config_entry_v4.entry_id) await hass.async_block_till_done() - await hass.config_entries.async_unload(config_entry_v3.entry_id) + await hass.config_entries.async_unload(config_entry_v4.entry_id) - assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE - assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path - assert config_entry_v3.version == 3 + assert config_entry_v4.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert config_entry_v4.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path + assert config_entry_v4.version == 4 + + +@pytest.mark.parametrize( + ( + "radio_type", + "old_baudrate", + "old_flow_control", + "new_baudrate", + "new_flow_control", + ), + [ + ("znp", None, None, 115200, None), + ("znp", None, "software", 115200, "software"), + ("znp", 57600, "software", 57600, "software"), + ("deconz", None, None, 38400, None), + ("deconz", 115200, None, 115200, None), + ], +) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_baudrate_and_flow_control( + radio_type: str, + old_baudrate: int, + old_flow_control: typing.Literal["hardware", "software", None], + new_baudrate: int, + new_flow_control: typing.Literal["hardware", "software", None], + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test baudrate and flow control migration.""" + config_entry.data = { + **config_entry.data, + CONF_RADIO_TYPE: radio_type, + CONF_DEVICE: { + CONF_BAUDRATE: old_baudrate, + CONF_FLOW_CONTROL: old_flow_control, + CONF_DEVICE_PATH: "/dev/null", + }, + } + config_entry.version = 3 + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version > 3 + assert config_entry.data[CONF_DEVICE][CONF_BAUDRATE] == new_baudrate + assert config_entry.data[CONF_DEVICE][CONF_FLOW_CONTROL] == new_flow_control @patch( diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 9c79578843c..d168e2e57b1 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -95,6 +95,7 @@ def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER +@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) @pytest.mark.parametrize( ("detected_hardware", "expected_learn_more_url"), [ @@ -188,6 +189,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( assert issue is None +@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -312,6 +314,8 @@ async def test_inconsistent_settings_keep_new( data = await resp.json() assert data["type"] == "create_entry" + await hass.config_entries.async_unload(config_entry.entry_id) + assert ( issue_registry.async_get_issue( domain=DOMAIN, @@ -388,6 +392,8 @@ async def test_inconsistent_settings_restore_old( data = await resp.json() assert data["type"] == "create_entry" + await hass.config_entries.async_unload(config_entry.entry_id) + assert ( issue_registry.async_get_issue( domain=DOMAIN, diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index d914c88c0c2..44006ea6ca1 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -62,7 +62,7 @@ from .conftest import ( ) from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS -from tests.common import MockUser +from tests.common import MockConfigEntry, MockUser IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -295,10 +295,12 @@ async def test_get_zha_config_with_alarm( async def test_update_zha_config( - zha_client, app_controller: ControllerApplication + hass: HomeAssistant, + config_entry: MockConfigEntry, + zha_client, + app_controller: ControllerApplication, ) -> None: """Test updating ZHA custom configuration.""" - configuration: dict = deepcopy(CONFIG_WITH_ALARM_OPTIONS) configuration["data"]["zha_options"]["default_light_transition"] = 10 @@ -312,10 +314,12 @@ async def test_update_zha_config( msg = await zha_client.receive_json() assert msg["success"] - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - msg = await zha_client.receive_json() - configuration = msg["result"] - assert configuration == configuration + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + msg = await zha_client.receive_json() + configuration = msg["result"] + assert configuration == configuration + + await hass.config_entries.async_unload(config_entry.entry_id) async def test_device_not_found(zha_client) -> None: From 6dc818b6822ce743eaf91d5f7fd2487c2047aa5a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 11:38:23 +0100 Subject: [PATCH 848/982] Add proj dependency to our wheels builder (#104699) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3b23f1b5b05..be9d393d9fd 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From d9c0acc1d27e8991b06afdf0fcdbbc580519b658 Mon Sep 17 00:00:00 2001 From: Stefan Rado <628587+kroimon@users.noreply.github.com> Date: Wed, 29 Nov 2023 11:45:15 +0100 Subject: [PATCH 849/982] Partially revert #103807: Remove deprecated aux heat support from ESPHome climate entities (#104694) --- homeassistant/components/esphome/climate.py | 16 ----- tests/components/esphome/test_climate.py | 66 +-------------------- 2 files changed, 1 insertion(+), 81 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 73b326204b5..08ed2f1109d 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -173,8 +173,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti features |= ClimateEntityFeature.TARGET_TEMPERATURE if self._static_info.supports_target_humidity: features |= ClimateEntityFeature.TARGET_HUMIDITY - if self._static_info.supports_aux_heat: - features |= ClimateEntityFeature.AUX_HEAT if self.preset_modes: features |= ClimateEntityFeature.PRESET_MODE if self.fan_modes: @@ -272,12 +270,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """Return the humidity we try to reach.""" return round(self._state.target_humidity) - @property - @esphome_state_property - def is_aux_heat(self) -> bool: - """Return the auxiliary heater state.""" - return self._state.aux_heat - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature (and operation mode if set).""" data: dict[str, Any] = {"key": self._key} @@ -326,11 +318,3 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti await self._client.climate_command( key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) ) - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - await self._client.climate_command(key=self._key, aux_heat=True) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - await self._client.climate_command(key=self._key, aux_heat=False) diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 8f0b8f96c56..065890fd623 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -15,7 +15,6 @@ from aioesphomeapi import ( ) from homeassistant.components.climate import ( - ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_FAN_MODE, ATTR_HUMIDITY, @@ -29,7 +28,6 @@ from homeassistant.components.climate import ( ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, FAN_HIGH, - SERVICE_SET_AUX_HEAT, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, @@ -39,7 +37,7 @@ from homeassistant.components.climate import ( SWING_BOTH, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -321,68 +319,6 @@ async def test_climate_entity_with_step_and_target_temp( mock_client.climate_command.reset_mock() -async def test_climate_entity_with_aux_heat( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry -) -> None: - """Test a generic climate entity with aux heat.""" - entity_info = [ - ClimateInfo( - object_id="myclimate", - key=1, - name="my climate", - unique_id="my_climate", - supports_current_temperature=True, - supports_two_point_target_temperature=True, - supports_action=True, - visual_min_temperature=10.0, - visual_max_temperature=30.0, - supports_aux_heat=True, - ) - ] - states = [ - ClimateState( - key=1, - mode=ClimateMode.HEAT, - action=ClimateAction.HEATING, - current_temperature=30, - target_temperature=20, - fan_mode=ClimateFanMode.AUTO, - swing_mode=ClimateSwingMode.BOTH, - aux_heat=True, - ) - ] - user_service = [] - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=entity_info, - user_service=user_service, - states=states, - ) - state = hass.states.get("climate.test_myclimate") - assert state is not None - assert state.state == HVACMode.HEAT - attributes = state.attributes - assert attributes[ATTR_AUX_HEAT] == STATE_ON - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_AUX_HEAT: False}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, aux_heat=False)]) - mock_client.climate_command.reset_mock() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_AUX_HEAT: True}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, aux_heat=True)]) - mock_client.climate_command.reset_mock() - - async def test_climate_entity_with_humidity( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: From 31cab5803c36ce7650dd89510b2dbf0283111c0b Mon Sep 17 00:00:00 2001 From: schelv <13403863+schelv@users.noreply.github.com> Date: Wed, 29 Nov 2023 12:25:06 +0100 Subject: [PATCH 850/982] Add Option For Kelvin Unit To Color Temperature Selector (#103799) --- homeassistant/components/light/services.yaml | 15 +++++------ homeassistant/helpers/selector.py | 28 ++++++++++++++++++-- tests/helpers/test_selector.py | 10 +++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 433da53a570..fb7a1539944 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -252,8 +252,9 @@ turn_on: - light.ColorMode.RGBWW selector: color_temp: - min_mireds: 153 - max_mireds: 500 + unit: "mired" + min: 153 + max: 500 kelvin: filter: attribute: @@ -266,11 +267,10 @@ turn_on: - light.ColorMode.RGBWW advanced: true selector: - number: + color_temp: + unit: "kelvin" min: 2000 max: 6500 - step: 100 - unit_of_measurement: K brightness: filter: attribute: @@ -637,11 +637,10 @@ toggle: - light.ColorMode.RGBWW advanced: true selector: - number: + color_temp: + unit: "kelvin" min: 2000 max: 6500 - step: 100 - unit_of_measurement: K brightness: filter: attribute: diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index ac5166911ff..bda2440cfb3 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -425,10 +425,20 @@ class ColorRGBSelector(Selector[ColorRGBSelectorConfig]): class ColorTempSelectorConfig(TypedDict, total=False): """Class to represent a color temp selector config.""" + unit: ColorTempSelectorUnit + min: int + max: int max_mireds: int min_mireds: int +class ColorTempSelectorUnit(StrEnum): + """Possible units for a color temperature selector.""" + + KELVIN = "kelvin" + MIRED = "mired" + + @SELECTORS.register("color_temp") class ColorTempSelector(Selector[ColorTempSelectorConfig]): """Selector of an color temperature.""" @@ -437,6 +447,11 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { + vol.Optional("unit", default=ColorTempSelectorUnit.MIRED): vol.All( + vol.Coerce(ColorTempSelectorUnit), lambda val: val.value + ), + vol.Optional("min"): vol.Coerce(int), + vol.Optional("max"): vol.Coerce(int), vol.Optional("max_mireds"): vol.Coerce(int), vol.Optional("min_mireds"): vol.Coerce(int), } @@ -448,11 +463,20 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): def __call__(self, data: Any) -> int: """Validate the passed selection.""" + range_min = self.config.get("min") + range_max = self.config.get("max") + + if not range_min: + range_min = self.config.get("min_mireds") + + if not range_max: + range_max = self.config.get("max_mireds") + value: int = vol.All( vol.Coerce(float), vol.Range( - min=self.config.get("min_mireds"), - max=self.config.get("max_mireds"), + min=range_min, + max=range_max, ), )(data) return value diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 1e449fd103a..93c342384fd 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -907,6 +907,16 @@ def test_rgb_color_selector_schema( (100, 200), (99, 201), ), + ( + {"unit": "mired", "min": 100, "max": 200}, + (100, 200), + (99, 201), + ), + ( + {"unit": "kelvin", "min": 1000, "max": 2000}, + (1000, 2000), + (999, 2001), + ), ), ) def test_color_tempselector_schema( From fc7b17d35b42e640499067e2e00a27b503c25540 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 29 Nov 2023 12:33:25 +0100 Subject: [PATCH 851/982] Handle server disconnection for Comelit devices (#104583) --- homeassistant/components/comelit/coordinator.py | 4 ++-- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index d3bc973429b..1573d5cb627 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -68,13 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + try: await self.api.login() + return await self.api.get_all_devices() except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed - - return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 77796ac7e7f..89157b54255 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.5.2"] + "requirements": ["aiocomelit==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 756de3bd306..fe298d256da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -212,7 +212,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.5.2 +aiocomelit==0.6.2 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbaaf1bcb16..6c8a9e7d10c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -191,7 +191,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.5.2 +aiocomelit==0.6.2 # homeassistant.components.dhcp aiodiscover==1.5.1 From 9741380cc09592b74eb34da11f66e48f4706a05f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 12:41:47 +0100 Subject: [PATCH 852/982] Add proj-util dependency to our wheels builder (#104708) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index be9d393d9fd..01cdf008917 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 861bb48ab6781436cbb214169f97bd4cdc666330 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 13:07:52 +0100 Subject: [PATCH 853/982] Assign specific error code for HomeAssistantError on websocket_api connection exceptions (#104700) Assign specific error code for HomeAssistantError --- homeassistant/components/websocket_api/connection.py | 2 +- .../components/application_credentials/test_init.py | 2 +- tests/components/blueprint/test_websocket_api.py | 4 ++-- tests/components/config/test_device_registry.py | 12 ++++++------ tests/components/fritzbox/test_init.py | 2 +- tests/components/group/test_config_flow.py | 2 +- tests/components/input_select/test_init.py | 4 ++-- tests/components/tag/test_init.py | 2 +- tests/components/template/test_config_flow.py | 2 +- tests/components/websocket_api/test_commands.py | 2 +- tests/components/websocket_api/test_connection.py | 8 ++++---- 11 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 551d4b0d1fe..25b6c90d1ba 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -271,7 +271,7 @@ class ActiveConnection: err_message = "Timeout" elif isinstance(err, HomeAssistantError): err_message = str(err) - code = const.ERR_UNKNOWN_ERROR + code = const.ERR_HOME_ASSISTANT_ERROR translation_domain = err.translation_domain translation_key = err.translation_key translation_placeholders = err.translation_placeholders diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index cc56894cf0d..807eff4ef8d 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -479,7 +479,7 @@ async def test_config_flow( resp = await client.cmd("delete", {"application_credentials_id": ID}) assert not resp.get("success") assert "error" in resp - assert resp["error"].get("code") == "unknown_error" + assert resp["error"].get("code") == "home_assistant_error" assert ( resp["error"].get("message") == "Cannot delete credential in use by integration fake_integration" diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 213dff89597..b0439896c25 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -438,7 +438,7 @@ async def test_delete_blueprint_in_use_by_automation( assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { - "code": "unknown_error", + "code": "home_assistant_error", "message": "Blueprint in use", } @@ -484,6 +484,6 @@ async def test_delete_blueprint_in_use_by_script( assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { - "code": "unknown_error", + "code": "home_assistant_error", "message": "Blueprint in use", } diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 87bb9cc9409..4a784a6eff1 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -242,7 +242,7 @@ async def test_remove_config_entry_from_device( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" # Make async_remove_config_entry_device return True can_remove = True @@ -365,7 +365,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device @@ -380,7 +380,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert ( response["error"]["message"] == "Config entry does not support device removal" ) @@ -397,7 +397,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to @@ -428,7 +428,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" # Try removing a config entry which can't be loaded from a device - allowed @@ -443,5 +443,5 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Integration not found" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 5c8d30772f0..b8273204325 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -296,7 +296,7 @@ async def test_remove_device( ) response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" await hass.async_block_till_done() # try to delete orphan_device diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 3189e344c62..7b83ed9eb0d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -699,4 +699,4 @@ async def test_option_flow_sensor_preview_config_entry_removed( ) msg = await client.receive_json() assert not msg["success"] - assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 03c503ae494..3978d0cf175 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -740,7 +740,7 @@ async def test_update_duplicates( ) resp = await client.receive_json() assert not resp["success"] - assert resp["error"]["code"] == "unknown_error" + assert resp["error"]["code"] == "home_assistant_error" assert resp["error"]["message"] == "Duplicate options are not allowed" state = hass.states.get(input_entity_id) @@ -812,7 +812,7 @@ async def test_ws_create_duplicates( ) resp = await client.receive_json() assert not resp["success"] - assert resp["error"]["code"] == "unknown_error" + assert resp["error"]["code"] == "home_assistant_error" assert resp["error"]["message"] == "Duplicate options are not allowed" assert not hass.states.get(input_entity_id) diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 3e034d2b9f2..5d54f31b13a 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -131,5 +131,5 @@ async def test_tag_id_exists( await client.send_json({"id": 2, "type": f"{DOMAIN}/create", "tag_id": "test tag"}) response = await client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert len(changes) == 0 diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index f4cfe90b9f0..b95a68afd85 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -845,4 +845,4 @@ async def test_option_flow_sensor_preview_config_entry_removed( ) msg = await client.receive_json() assert not msg["success"] - assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c573fb85bb1..127b45484be 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2327,7 +2327,7 @@ async def test_execute_script( translation_key="test_error", translation_placeholders={"option": "bla"}, ), - "unknown_error", + "home_assistant_error", ), ( ServiceValidationError( diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index 4bc736481c4..80936d30752 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -39,15 +39,15 @@ from tests.common import MockUser ), ( exceptions.HomeAssistantError("Failed to do X"), - websocket_api.ERR_UNKNOWN_ERROR, + websocket_api.ERR_HOME_ASSISTANT_ERROR, "Failed to do X", - "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", + "Error handling message: Failed to do X (home_assistant_error) Mock User from 127.0.0.42 (Browser)", ), ( exceptions.ServiceValidationError("Failed to do X"), - websocket_api.ERR_UNKNOWN_ERROR, + websocket_api.ERR_HOME_ASSISTANT_ERROR, "Failed to do X", - "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", + "Error handling message: Failed to do X (home_assistant_error) Mock User from 127.0.0.42 (Browser)", ), ( ValueError("Really bad"), From 5f44dadb66068a04982340d8cce941e890f9536d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 29 Nov 2023 13:11:15 +0100 Subject: [PATCH 854/982] Rename todo due_date_time parameter to due_datetime (#104698) * Rename todo due_date_time parameter to due_datetime * Apply suggestions from code review --------- Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- homeassistant/components/todo/__init__.py | 8 +++----- homeassistant/components/todo/const.py | 2 +- homeassistant/components/todo/services.yaml | 4 ++-- homeassistant/components/todo/strings.json | 8 ++++---- tests/components/local_todo/test_todo.py | 6 +++--- tests/components/todo/test_init.py | 14 +++++++------- 6 files changed, 20 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 814138dcb7f..c0e0303d76e 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -35,7 +35,7 @@ from .const import ( ATTR_DESCRIPTION, ATTR_DUE, ATTR_DUE_DATE, - ATTR_DUE_DATE_TIME, + ATTR_DUE_DATETIME, DOMAIN, TodoItemStatus, TodoListEntityFeature, @@ -73,7 +73,7 @@ TODO_ITEM_FIELDS = [ required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, ), TodoItemFieldDescription( - service_field=ATTR_DUE_DATE_TIME, + service_field=ATTR_DUE_DATETIME, validation=vol.All(cv.datetime, dt_util.as_local), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, @@ -89,9 +89,7 @@ TODO_ITEM_FIELDS = [ TODO_ITEM_FIELD_SCHEMA = { vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS } -TODO_ITEM_FIELD_VALIDATIONS = [ - cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATE_TIME) -] +TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)] def _validate_supported_features( diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index 95e190cb3e3..a605f9fcba2 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -6,7 +6,7 @@ DOMAIN = "todo" ATTR_DUE = "due" ATTR_DUE_DATE = "due_date" -ATTR_DUE_DATE_TIME = "due_date_time" +ATTR_DUE_DATETIME = "due_datetime" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 390aa82753a..bc7da7db941 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -29,7 +29,7 @@ add_item: example: "2023-11-17" selector: date: - due_date_time: + due_datetime: example: "2023-11-17 13:30:00" selector: datetime: @@ -65,7 +65,7 @@ update_item: example: "2023-11-17" selector: date: - due_date_time: + due_datetime: example: "2023-11-17 13:30:00" selector: datetime: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index bca32f850eb..3da921a8f47 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -28,8 +28,8 @@ "name": "Due date", "description": "The date the to-do item is expected to be completed." }, - "due_date_time": { - "name": "Due date time", + "due_datetime": { + "name": "Due date and time", "description": "The date and time the to-do item is expected to be completed." }, "description": { @@ -58,8 +58,8 @@ "name": "Due date", "description": "The date the to-do item is expected to be completed." }, - "due_date_time": { - "name": "Due date time", + "due_datetime": { + "name": "Due date and time", "description": "The date and time the to-do item is expected to be completed." }, "description": { diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index b2c79ef4bd1..67d0703ca7c 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -71,7 +71,7 @@ def set_time_zone(hass: HomeAssistant) -> None: ({}, {}), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, ), ({"description": "Additional detail"}, {"description": "Additional detail"}), @@ -118,7 +118,7 @@ async def test_add_item( ({}, {}), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, ), ({"description": "Additional detail"}, {"description": "Additional detail"}), @@ -213,7 +213,7 @@ async def test_bulk_remove( ({"status": "completed"}, {"status": "completed"}, "0"), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, "1", ), diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 0071d4ada86..90b06858e00 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -354,10 +354,10 @@ async def test_add_item_service_raises( ( { "item": "Submit forms", - "due_date_time": f"2023-11-17T17:00:00{TEST_OFFSET}", + "due_datetime": f"2023-11-17T17:00:00{TEST_OFFSET}", }, ValueError, - "does not support setting field 'due_date_time'", + "does not support setting field 'due_datetime'", ), ], ) @@ -396,7 +396,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + {"item": "New item", "due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -405,7 +405,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": "2023-11-13T17:00:00+00:00"}, + {"item": "New item", "due_datetime": "2023-11-13T17:00:00+00:00"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -414,7 +414,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": "2023-11-13"}, + {"item": "New item", "due_datetime": "2023-11-13"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -663,7 +663,7 @@ async def test_update_item_service_invalid_input( @pytest.mark.parametrize( ("update_data"), [ - ({"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}), + ({"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}), ({"due_date": "2023-11-13"}), ({"description": "Submit revised draft"}), ], @@ -697,7 +697,7 @@ async def test_update_todo_item_field_unsupported( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + {"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( uid="1", due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), From cf23de1c488e3d7b89a76adf4cc26b5584a05ba6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 13:15:37 +0100 Subject: [PATCH 855/982] Add proj-dev dependency to our wheels builder (#104711) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 01cdf008917..9d16954cd09 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From e5a7446afefb0d9dfd47b0fae803a7b7314f13e4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 29 Nov 2023 13:35:32 +0100 Subject: [PATCH 856/982] Use id as location data in Trafikverket Camera (#104473) --- .../trafikverket_camera/__init__.py | 43 +++++++++--- .../trafikverket_camera/config_flow.py | 18 ++--- .../trafikverket_camera/coordinator.py | 8 +-- .../trafikverket_camera/__init__.py | 7 +- .../trafikverket_camera/conftest.py | 6 +- .../trafikverket_camera/test_binary_sensor.py | 2 +- .../trafikverket_camera/test_camera.py | 8 +-- .../trafikverket_camera/test_config_flow.py | 20 +++--- .../trafikverket_camera/test_coordinator.py | 16 ++--- .../trafikverket_camera/test_init.py | 68 ++++++++++++++----- .../trafikverket_camera/test_recorder.py | 6 +- .../trafikverket_camera/test_sensor.py | 12 ++-- 12 files changed, 135 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index d9d28cfe13b..3ac3ce35882 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -6,7 +6,7 @@ import logging from pytrafikverket.trafikverket_camera import TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -42,13 +42,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" + api_key = entry.data[CONF_API_KEY] + web_session = async_get_clientsession(hass) + camera_api = TrafikverketCamera(web_session, api_key) # Change entry unique id from location to camera id if entry.version == 1: location = entry.data[CONF_LOCATION] - api_key = entry.data[CONF_API_KEY] - - web_session = async_get_clientsession(hass) - camera_api = TrafikverketCamera(web_session, api_key) try: camera_info = await camera_api.async_get_camera(location) @@ -60,14 +59,40 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if camera_id := camera_info.camera_id: entry.version = 2 - _LOGGER.debug( - "Migrate Trafikverket Camera config entry unique id to %s", - camera_id, - ) hass.config_entries.async_update_entry( entry, unique_id=f"{DOMAIN}-{camera_id}", ) + _LOGGER.debug( + "Migrated Trafikverket Camera config entry unique id to %s", + camera_id, + ) + else: + _LOGGER.error("Could not migrate the config entry. Camera has no id") + return False + + # Change entry data from location to id + if entry.version == 2: + location = entry.data[CONF_LOCATION] + + try: + camera_info = await camera_api.async_get_camera(location) + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Could not migrate the config entry. No connection to the api" + ) + return False + + if camera_id := camera_info.camera_id: + entry.version = 3 + _LOGGER.debug( + "Migrate Trafikverket Camera config entry unique id to %s", + camera_id, + ) + new_data = entry.data.copy() + new_data.pop(CONF_LOCATION) + new_data[CONF_ID] = camera_id + hass.config_entries.async_update_entry(entry, data=new_data) return True _LOGGER.error("Could not migrate the config entry. Camera has no id") return False diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index e75bc0bfa30..7572855b7d4 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -25,7 +25,7 @@ from .const import CONF_LOCATION, DOMAIN class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Camera integration.""" - VERSION = 2 + VERSION = 3 entry: config_entries.ConfigEntry | None @@ -53,10 +53,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if camera_info: camera_id = camera_info.camera_id - if _location := camera_info.location: - camera_location = _location - else: - camera_location = camera_info.camera_name + camera_location = camera_info.camera_name or "Trafikverket Camera" return (errors, camera_location, camera_id) @@ -76,9 +73,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors, _, _ = await self.validate_input( - api_key, self.entry.data[CONF_LOCATION] - ) + errors, _, _ = await self.validate_input(api_key, self.entry.data[CONF_ID]) if not errors: self.hass.config_entries.async_update_entry( @@ -121,10 +116,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( title=camera_location, - data={ - CONF_API_KEY: api_key, - CONF_LOCATION: camera_location, - }, + data={CONF_API_KEY: api_key, CONF_ID: camera_id}, ) return self.async_show_form( diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index eb5a047ca73..8270fecd487 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -15,13 +15,13 @@ from pytrafikverket.exceptions import ( from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_LOCATION, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) TIME_BETWEEN_UPDATES = timedelta(minutes=5) @@ -48,14 +48,14 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): ) self.session = async_get_clientsession(hass) self._camera_api = TrafikverketCamera(self.session, entry.data[CONF_API_KEY]) - self._location = entry.data[CONF_LOCATION] + self._id = entry.data[CONF_ID] async def _async_update_data(self) -> CameraData: """Fetch data from Trafikverket.""" camera_data: CameraInfo image: bytes | None = None try: - camera_data = await self._camera_api.async_get_camera(self._location) + camera_data = await self._camera_api.async_get_camera(self._id) except (NoCameraFound, MultipleCamerasFound, UnknownError) as error: raise UpdateFailed from error except InvalidAuthentication as error: diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py index 026c122fb57..a9aa3ad70d1 100644 --- a/tests/components/trafikverket_camera/__init__.py +++ b/tests/components/trafikverket_camera/__init__.py @@ -2,9 +2,14 @@ from __future__ import annotations from homeassistant.components.trafikverket_camera.const import CONF_LOCATION -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_ID: "1234", +} + +ENTRY_CONFIG_OLD_CONFIG = { CONF_API_KEY: "1234567890", CONF_LOCATION: "Test location", } diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index a4902ac2950..a5eeb707b34 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -32,9 +32,9 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) config_entry.add_to_hass(hass) @@ -54,7 +54,7 @@ def fixture_get_camera() -> CameraInfo: """Construct Camera Mock.""" return CameraInfo( - camera_name="Test_camera", + camera_name="Test Camera", camera_id="1234", active=True, deleted=False, diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py index 6f7eb540289..87d0e6d58b7 100644 --- a/tests/components/trafikverket_camera/test_binary_sensor.py +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -16,5 +16,5 @@ async def test_sensor( ) -> None: """Test the Trafikverket Camera binary sensor.""" - state = hass.states.get("binary_sensor.test_location_active") + state = hass.states.get("binary_sensor.test_camera_active") assert state.state == STATE_ON diff --git a/tests/components/trafikverket_camera/test_camera.py b/tests/components/trafikverket_camera/test_camera.py index b3df7cfcdcb..182924e9f0e 100644 --- a/tests/components/trafikverket_camera/test_camera.py +++ b/tests/components/trafikverket_camera/test_camera.py @@ -26,7 +26,7 @@ async def test_camera( get_camera: CameraInfo, ) -> None: """Test the Trafikverket Camera sensor.""" - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes["description"] == "Test Camera for testing" assert state1.attributes["location"] == "Test location" @@ -44,11 +44,11 @@ async def test_camera( async_fire_time_changed(hass) await hass.async_block_till_done() - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes != {} - assert await async_get_image(hass, "camera.test_location") + assert await async_get_image(hass, "camera.test_camera") monkeypatch.setattr( get_camera, @@ -69,4 +69,4 @@ async def test_camera( await hass.async_block_till_done() with pytest.raises(HomeAssistantError): - await async_get_image(hass, "camera.test_location") + await async_get_image(hass, "camera.test_camera") diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index b53763c0ac7..305066832e5 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -47,10 +47,10 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test location" + assert result2["title"] == "Test Camera" assert result2["data"] == { "api_key": "1234567890", - "location": "Test location", + "id": "1234", } assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == "trafikverket_camera-1234" @@ -87,7 +87,7 @@ async def test_form_no_location_data( assert result2["title"] == "Test Camera" assert result2["data"] == { "api_key": "1234567890", - "location": "Test Camera", + "id": "1234", } assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == "trafikverket_camera-1234" @@ -150,10 +150,10 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: domain=DOMAIN, data={ CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_ID: "1234", }, unique_id="1234", - version=2, + version=3, ) entry.add_to_hass(hass) @@ -186,7 +186,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", - "location": "Test location", + "id": "1234", } @@ -223,10 +223,10 @@ async def test_reauth_flow_error( domain=DOMAIN, data={ CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_ID: "1234", }, unique_id="1234", - version=2, + version=3, ) entry.add_to_hass(hass) await hass.async_block_till_done() @@ -271,5 +271,5 @@ async def test_reauth_flow_error( assert result2["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", - "location": "Test location", + "id": "1234", } diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py index 4183aa9fffa..0f79307e0b6 100644 --- a/tests/components/trafikverket_camera/test_coordinator.py +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -40,9 +40,9 @@ async def test_coordinator( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -54,7 +54,7 @@ async def test_coordinator( await hass.async_block_till_done() mock_data.assert_called_once() - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" @@ -101,9 +101,9 @@ async def test_coordinator_failed_update( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -115,7 +115,7 @@ async def test_coordinator_failed_update( await hass.async_block_till_done() mock_data.assert_called_once() - state = hass.states.get("camera.test_location") + state = hass.states.get("camera.test_camera") assert state is None assert entry.state == entry_state @@ -135,7 +135,7 @@ async def test_coordinator_failed_get_image( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", title="Test location", ) @@ -149,6 +149,6 @@ async def test_coordinator_failed_get_image( await hass.async_block_till_done() mock_data.assert_called_once() - state = hass.states.get("camera.test_location") + state = hass.states.get("camera.test_camera") assert state is None assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py index 83a3fc1486a..e10c6c16e33 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime from unittest.mock import patch +import pytest from pytrafikverket.exceptions import UnknownError from pytrafikverket.trafikverket_camera import CameraInfo @@ -14,7 +15,7 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import ENTRY_CONFIG +from . import ENTRY_CONFIG, ENTRY_CONFIG_OLD_CONFIG from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -35,9 +36,9 @@ async def test_setup_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -67,9 +68,9 @@ async def test_unload_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -99,7 +100,7 @@ async def test_migrate_entry( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", unique_id="trafikverket_camera-Test location", title="Test location", @@ -114,15 +115,31 @@ async def test_migrate_entry( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.LOADED - assert entry.version == 2 + assert entry.version == 3 assert entry.unique_id == "trafikverket_camera-1234" - assert len(mock_tvt_camera.mock_calls) == 2 + assert entry.data == ENTRY_CONFIG + assert len(mock_tvt_camera.mock_calls) == 3 +@pytest.mark.parametrize( + ("version", "unique_id"), + [ + ( + 1, + "trafikverket_camera-Test location", + ), + ( + 2, + "trafikverket_camera-1234", + ), + ], +) async def test_migrate_entry_fails_with_error( hass: HomeAssistant, get_camera: CameraInfo, aioclient_mock: AiohttpClientMocker, + version: int, + unique_id: str, ) -> None: """Test migrate entry fails with api error.""" aioclient_mock.get( @@ -132,9 +149,10 @@ async def test_migrate_entry_fails_with_error( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", - unique_id="trafikverket_camera-Test location", + version=version, + unique_id=unique_id, title="Test location", ) entry.add_to_hass(hass) @@ -147,14 +165,29 @@ async def test_migrate_entry_fails_with_error( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.unique_id == "trafikverket_camera-Test location" + assert entry.version == version + assert entry.unique_id == unique_id assert len(mock_tvt_camera.mock_calls) == 1 +@pytest.mark.parametrize( + ("version", "unique_id"), + [ + ( + 1, + "trafikverket_camera-Test location", + ), + ( + 2, + "trafikverket_camera-1234", + ), + ], +) async def test_migrate_entry_fails_no_id( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + version: int, + unique_id: str, ) -> None: """Test migrate entry fails, camera returns no id.""" aioclient_mock.get( @@ -164,9 +197,10 @@ async def test_migrate_entry_fails_no_id( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", - unique_id="trafikverket_camera-Test location", + version=version, + unique_id=unique_id, title="Test location", ) entry.add_to_hass(hass) @@ -195,8 +229,8 @@ async def test_migrate_entry_fails_no_id( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.unique_id == "trafikverket_camera-Test location" + assert entry.version == version + assert entry.unique_id == unique_id assert len(mock_tvt_camera.mock_calls) == 1 @@ -214,7 +248,7 @@ async def test_no_migration_needed( domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, - version=2, + version=3, entry_id="1234", unique_id="trafikverket_camera-1234", title="Test location", diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index b9add7ae483..777c6ea26b3 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -24,7 +24,7 @@ async def test_exclude_attributes( get_camera: CameraInfo, ) -> None: """Test camera has description and location excluded from recording.""" - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes["description"] == "Test Camera for testing" assert state1.attributes["location"] == "Test location" @@ -39,10 +39,10 @@ async def test_exclude_attributes( hass.states.async_entity_ids(), ) assert len(states) == 8 - assert states.get("camera.test_location") + assert states.get("camera.test_camera") for entity_states in states.values(): for state in entity_states: - if state.entity_id == "camera.test_location": + if state.entity_id == "camera.test_camera": assert "location" not in state.attributes assert "description" not in state.attributes assert "type" in state.attributes diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py index 581fed1d289..c1c98aed797 100644 --- a/tests/components/trafikverket_camera/test_sensor.py +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -15,15 +15,15 @@ async def test_sensor( ) -> None: """Test the Trafikverket Camera sensor.""" - state = hass.states.get("sensor.test_location_direction") + state = hass.states.get("sensor.test_camera_direction") assert state.state == "180" - state = hass.states.get("sensor.test_location_modified") + state = hass.states.get("sensor.test_camera_modified") assert state.state == "2022-04-04T04:04:04+00:00" - state = hass.states.get("sensor.test_location_photo_time") + state = hass.states.get("sensor.test_camera_photo_time") assert state.state == "2022-04-04T04:04:04+00:00" - state = hass.states.get("sensor.test_location_photo_url") + state = hass.states.get("sensor.test_camera_photo_url") assert state.state == "https://www.testurl.com/test_photo.jpg" - state = hass.states.get("sensor.test_location_status") + state = hass.states.get("sensor.test_camera_status") assert state.state == "Running" - state = hass.states.get("sensor.test_location_camera_type") + state = hass.states.get("sensor.test_camera_camera_type") assert state.state == "Road" From 49381cefa3d2e384bdbbb73f33cffadf30c53204 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Nov 2023 13:37:23 +0100 Subject: [PATCH 857/982] Update frontend to 20231129.0 (#104710) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 469deab23e1..6a6c557c7b7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231030.2"] + "requirements": ["home-assistant-frontend==20231129.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9ea82cb3e75..4aa2a4ea013 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231129.0 home-assistant-intents==2023.11.17 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index fe298d256da..f76a27f0d8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231129.0 # homeassistant.components.conversation home-assistant-intents==2023.11.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c8a9e7d10c..64cd02ec6c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231129.0 # homeassistant.components.conversation home-assistant-intents==2023.11.17 From 953a212dd697c5b916a335869bed8b36be466b92 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 13:56:51 +0100 Subject: [PATCH 858/982] Use ServiceValidationError for invalid fan preset_mode and move check to fan entity component (#104560) * Use ServiceValidationError for fan preset_mode * Use _valid_preset_mode_or_raise to raise * Move preset_mode validation to entity component * Fix bond fan and comments * Fixes baf, fjaraskupan and template * More integration adjustments * Add custom components mock and test code * Make NotValidPresetModeError subclass * Update homeassistant/components/fan/strings.json Co-authored-by: Martin Hjelmare * Keep bond has_action validation * Move demo test asserts outside context block * Follow up comment * Update homeassistant/components/fan/strings.json Co-authored-by: G Johansson * Fix demo tests * Remove pylint disable * Remove unreachable code * Update homeassistant/components/fan/__init__.py Co-authored-by: G Johansson * Use NotValidPresetModeError, Final methods * Address comments * Correct docst * Follow up comments * Update homeassistant/components/fan/__init__.py Co-authored-by: Erik Montnemery --------- Co-authored-by: Martin Hjelmare Co-authored-by: G Johansson Co-authored-by: Erik Montnemery --- homeassistant/components/baf/fan.py | 2 - homeassistant/components/bond/fan.py | 4 -- homeassistant/components/demo/fan.py | 13 +--- homeassistant/components/fan/__init__.py | 49 ++++++++++++-- homeassistant/components/fan/strings.json | 5 ++ homeassistant/components/fjaraskupan/fan.py | 8 +-- homeassistant/components/mqtt/fan.py | 2 - homeassistant/components/template/fan.py | 9 --- homeassistant/components/tradfri/fan.py | 3 +- homeassistant/components/vallox/fan.py | 12 +--- homeassistant/components/xiaomi_miio/fan.py | 22 ------- homeassistant/components/zha/fan.py | 6 -- homeassistant/components/zwave_js/fan.py | 6 -- tests/components/bond/test_fan.py | 9 ++- tests/components/demo/test_fan.py | 24 +++++-- tests/components/fan/test_init.py | 65 ++++++++++++++++++- tests/components/mqtt/test_fan.py | 40 +++++++----- tests/components/template/test_fan.py | 16 +++-- tests/components/vallox/test_fan.py | 4 +- tests/components/zha/test_fan.py | 9 ++- tests/components/zwave_js/test_fan.py | 6 +- .../custom_components/test/fan.py | 64 ++++++++++++++++++ 22 files changed, 260 insertions(+), 118 deletions(-) create mode 100644 tests/testing_config/custom_components/test/fan.py diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 059603fc589..e2d1c5fcb3a 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -93,8 +93,6 @@ class BAFFan(BAFEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode != PRESET_MODE_AUTO: - raise ValueError(f"Invalid preset mode: {preset_mode}") self._device.fan_mode = OffOnAuto.AUTO async def async_set_direction(self, direction: str) -> None: diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index bc6235cb219..3cb81ba40b4 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -199,10 +199,6 @@ class BondFan(BondEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode != PRESET_MODE_BREEZE or not self._device.has_action( - Action.BREEZE_ON - ): - raise ValueError(f"Invalid preset mode: {preset_mode}") await self._hub.bond.action(self._device.device_id, Action(Action.BREEZE_ON)) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 211389a5466..73cae4a64b1 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -161,12 +161,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.preset_modes and preset_mode in self.preset_modes: - self._preset_mode = preset_mode - self._percentage = None - self.schedule_update_ha_state() - else: - raise ValueError(f"Invalid preset mode: {preset_mode}") + self._preset_mode = preset_mode + self._percentage = None + self.schedule_update_ha_state() def turn_on( self, @@ -230,10 +227,6 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.preset_modes is None or preset_mode not in self.preset_modes: - raise ValueError( - f"{preset_mode} is not a valid preset_mode: {self.preset_modes}" - ) self._preset_mode = preset_mode self._percentage = None self.async_write_ha_state() diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index a149909e029..21ffca35962 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -18,7 +18,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -77,8 +78,19 @@ ATTR_PRESET_MODES = "preset_modes" # mypy: disallow-any-generics -class NotValidPresetModeError(ValueError): - """Exception class when the preset_mode in not in the preset_modes list.""" +class NotValidPresetModeError(ServiceValidationError): + """Raised when the preset_mode is not in the preset_modes list.""" + + def __init__( + self, *args: object, translation_placeholders: dict[str, str] | None = None + ) -> None: + """Initialize the exception.""" + super().__init__( + *args, + translation_domain=DOMAIN, + translation_key="not_valid_preset_mode", + translation_placeholders=translation_placeholders, + ) @bind_hass @@ -107,7 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), vol.Optional(ATTR_PRESET_MODE): cv.string, }, - "async_turn_on", + "async_handle_turn_on_service", ) component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") @@ -156,7 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_PRESET_MODE, {vol.Required(ATTR_PRESET_MODE): cv.string}, - "async_set_preset_mode", + "async_handle_set_preset_mode_service", [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE], ) @@ -237,17 +249,30 @@ class FanEntity(ToggleEntity): """Set new preset mode.""" raise NotImplementedError() + @final + async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_preset_mode_or_raise(preset_mode) + await self.async_set_preset_mode(preset_mode) + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) + @final + @callback def _valid_preset_mode_or_raise(self, preset_mode: str) -> None: """Raise NotValidPresetModeError on invalid preset_mode.""" preset_modes = self.preset_modes if not preset_modes or preset_mode not in preset_modes: + preset_modes_str: str = ", ".join(preset_modes or []) raise NotValidPresetModeError( f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {preset_modes}" + f" {preset_modes}", + translation_placeholders={ + "preset_mode": preset_mode, + "preset_modes": preset_modes_str, + }, ) def set_direction(self, direction: str) -> None: @@ -267,6 +292,18 @@ class FanEntity(ToggleEntity): """Turn on the fan.""" raise NotImplementedError() + @final + async def async_handle_turn_on_service( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Validate and turn on the fan.""" + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + await self.async_turn_on(percentage, preset_mode, **kwargs) + async def async_turn_on( self, percentage: int | None = None, diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 674dcc2b92e..aab714d3e07 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -144,5 +144,10 @@ "reverse": "Reverse" } } + }, + "exceptions": { + "not_valid_preset_mode": { + "message": "Preset mode {preset_mode} is not valid, valid preset modes are: {preset_modes}." + } } } diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 142694a6bfb..ee989bb2ee0 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -131,11 +131,9 @@ class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if command := PRESET_TO_COMMAND.get(preset_mode): - async with self.coordinator.async_connect_and_update() as device: - await device.send_command(command) - else: - raise UnsupportedPreset(f"The preset {preset_mode} is unsupported") + command = PRESET_TO_COMMAND[preset_mode] + async with self.coordinator.async_connect_and_update() as device: + await device.send_command(command) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 0e9e7d708e9..e3dcf66c8b1 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -553,8 +553,6 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ - self._valid_preset_mode_or_raise(preset_mode) - mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode) await self.async_publish( diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index d39fa56775a..8aeede42552 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -282,15 +282,6 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" - if self.preset_modes and preset_mode not in self.preset_modes: - _LOGGER.error( - "Received invalid preset_mode: %s for entity %s. Expected: %s", - preset_mode, - self.entity_id, - self.preset_modes, - ) - return - self._preset_mode = preset_mode if self._set_preset_mode_script: diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index c41b24a2647..5c0f05004ba 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -119,8 +119,7 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): if not self._device_control: return - if not preset_mode == ATTR_AUTO: - raise ValueError("Preset must be 'Auto'.") + # Preset must be 'Auto' await self._api(self._device_control.turn_on_auto_mode()) diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 2f420096c74..e58c3ebd88d 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -11,11 +11,7 @@ from vallox_websocket_api import ( ValloxInvalidInputException, ) -from homeassistant.components.fan import ( - FanEntity, - FanEntityFeature, - NotValidPresetModeError, -) +from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -200,12 +196,6 @@ class ValloxFanEntity(ValloxEntity, FanEntity): Returns true if the mode has been changed, false otherwise. """ - try: - self._valid_preset_mode_or_raise(preset_mode) - - except NotValidPresetModeError as err: - raise ValueError(f"Not valid preset mode: {preset_mode}") from err - if preset_mode == self.preset_mode: return False diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index a3bb28e7a8b..9be019ed724 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -530,9 +530,6 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): This method is a coroutine. """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -623,9 +620,6 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -721,9 +715,6 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): This method is a coroutine. """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -809,9 +800,6 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. This method is a coroutine.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -958,10 +946,6 @@ class XiaomiFan(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return - if preset_mode == ATTR_MODE_NATURE: await self._try_command( "Setting natural fan speed percentage of the miio device failed.", @@ -1034,9 +1018,6 @@ class XiaomiFanP5(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -1093,9 +1074,6 @@ class XiaomiFanMiot(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 05bf3469c7b..c6b9a104885 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -13,7 +13,6 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -131,11 +130,6 @@ class BaseFan(FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the fan.""" - if preset_mode not in self.preset_modes: - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) @abstractmethod diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index d0630649765..d4247b65c8b 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -18,7 +18,6 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -181,11 +180,6 @@ class ValueMappingZwaveFan(ZwaveFan): await self._async_set_value(self._target_value, zwave_value) return - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) - @property def available(self) -> bool: """Return whether the entity is available.""" diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index db1c0fc787d..e202433c8d6 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -26,6 +26,7 @@ from homeassistant.components.fan import ( SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -251,10 +252,14 @@ async def test_turn_on_fan_preset_mode_not_supported(hass: HomeAssistant) -> Non props={"max_speed": 6}, ) - with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + NotValidPresetModeError + ): await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE) - with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + NotValidPresetModeError + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 58a8c99ea3c..a3f607aee76 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -182,7 +182,7 @@ async def test_turn_on_with_preset_mode_only( assert state.state == STATE_OFF assert state.attributes[fan.ATTR_PRESET_MODE] is None - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -190,6 +190,12 @@ async def test_turn_on_with_preset_mode_only( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_placeholders == { + "preset_mode": "invalid", + "preset_modes": "auto, smart, sleep, on", + } state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF @@ -250,7 +256,7 @@ async def test_turn_on_with_preset_mode_and_speed( assert state.attributes[fan.ATTR_PERCENTAGE] == 0 assert state.attributes[fan.ATTR_PRESET_MODE] is None - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -258,6 +264,12 @@ async def test_turn_on_with_preset_mode_and_speed( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_placeholders == { + "preset_mode": "invalid", + "preset_modes": "auto, smart, sleep, on", + } state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF @@ -343,7 +355,7 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_PRESET_MODE, @@ -351,8 +363,10 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -360,6 +374,8 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 8338afc9c68..ec421141768 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,8 +1,19 @@ """Tests for fan platforms.""" import pytest -from homeassistant.components.fan import FanEntity +from homeassistant.components.fan import ( + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN, + SERVICE_SET_PRESET_MODE, + FanEntity, + NotValidPresetModeError, +) from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test.fan import MockFan class BaseFan(FanEntity): @@ -82,3 +93,55 @@ def test_fanentity_attributes(attribute_name, attribute_value) -> None: fan = BaseFan() setattr(fan, f"_attr_{attribute_name}", attribute_value) assert getattr(fan, attribute_name) == attribute_value + + +async def test_preset_mode_validation( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test preset mode validation.""" + + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.fan") + platform.init(empty=False) + + assert await async_setup_component(hass, "fan", {"fan": {"platform": "test"}}) + await hass.async_block_till_done() + + test_fan: MockFan = platform.ENTITIES["support_preset_mode"] + await hass.async_block_till_done() + + state = hass.states.get("fan.support_fan_with_preset_mode_support") + assert state.attributes.get(ATTR_PRESET_MODES) == ["auto", "eco"] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "fan.support_fan_with_preset_mode_support", + "preset_mode": "eco", + }, + blocking=True, + ) + + state = hass.states.get("fan.support_fan_with_preset_mode_support") + assert state.attributes.get(ATTR_PRESET_MODE) == "eco" + + with pytest.raises(NotValidPresetModeError) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "fan.support_fan_with_preset_mode_support", + "preset_mode": "invalid", + }, + blocking=True, + ) + assert exc.value.translation_key == "not_valid_preset_mode" + + with pytest.raises(NotValidPresetModeError) as exc: + await test_fan._valid_preset_mode_or_raise("invalid") + assert exc.value.translation_key == "not_valid_preset_mode" diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 21d3bcce3a9..e7c4eba54e2 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -705,8 +705,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -916,11 +917,13 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "auto") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -976,8 +979,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="freaking-high") + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize( @@ -1078,11 +1082,13 @@ async def test_sending_mqtt_command_templates_( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1140,8 +1146,9 @@ async def test_sending_mqtt_command_templates_( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="low") + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize( @@ -1176,8 +1183,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1276,11 +1284,10 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="auto") - assert mqtt_mock.async_publish.call_count == 1 - # We can turn on, but the invalid preset mode will raise - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + assert exc.value.translation_key == "not_valid_preset_mode" + assert mqtt_mock.async_publish.call_count == 0 mqtt_mock.async_publish.reset_mock() await common.async_turn_on(hass, "fan.test", preset_mode="whoosh") @@ -1428,11 +1435,13 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( with pytest.raises(MultipleInvalid): await common.async_set_percentage(hass, "fan.test", 101) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1452,8 +1461,9 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "freaking-high") + assert exc.value.translation_key == "not_valid_preset_mode" mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index f9b0bddddcf..ccdafebd8bb 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -12,6 +12,7 @@ from homeassistant.components.fan import ( DIRECTION_REVERSE, DOMAIN, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -489,7 +490,11 @@ async def test_preset_modes(hass: HomeAssistant, calls) -> None: ("smart", "smart", 3), ("invalid", "smart", 3), ]: - await common.async_set_preset_mode(hass, _TEST_FAN, extra) + if extra != state: + with pytest.raises(NotValidPresetModeError): + await common.async_set_preset_mode(hass, _TEST_FAN, extra) + else: + await common.async_set_preset_mode(hass, _TEST_FAN, extra) assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == state assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_preset_mode" @@ -550,6 +555,7 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: with assert_setup_component(1, "fan"): test_fan_config = { "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "preset_modes": ["auto"], "percentage_template": "{{ states('input_number.percentage') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", @@ -625,18 +631,18 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() await common.async_turn_on(hass, _TEST_FAN) - _verify(hass, STATE_ON, 0, None, None, None) + _verify(hass, STATE_ON, 0, None, None, "auto") await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, 0, None, None, None) + _verify(hass, STATE_OFF, 0, None, None, "auto") percent = 100 await common.async_set_percentage(hass, _TEST_FAN, percent) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent - _verify(hass, STATE_ON, percent, None, None, None) + _verify(hass, STATE_ON, percent, None, None, "auto") await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, percent, None, None, None) + _verify(hass, STATE_OFF, percent, None, None, "auto") preset = "auto" await common.async_set_preset_mode(hass, _TEST_FAN, preset) diff --git a/tests/components/vallox/test_fan.py b/tests/components/vallox/test_fan.py index eb60a3d025d..12b24f46aba 100644 --- a/tests/components/vallox/test_fan.py +++ b/tests/components/vallox/test_fan.py @@ -10,6 +10,7 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, + NotValidPresetModeError, ) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant @@ -179,7 +180,7 @@ async def test_set_invalid_preset_mode( """Test set preset mode.""" await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - with pytest.raises(ValueError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -189,6 +190,7 @@ async def test_set_invalid_preset_mode( }, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" async def test_set_preset_mode_exception( diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 737604482d8..7d45960d576 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -222,10 +222,11 @@ async def test_fan( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode( hass, entity_id, preset_mode="invalid does not exist" ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA @@ -624,10 +625,11 @@ async def test_fan_ikea( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode( hass, entity_id, preset_mode="invalid does not exist" ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA @@ -813,8 +815,9 @@ async def test_fan_kof( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 92141eec3ff..c26a5366d37 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -536,13 +536,14 @@ async def test_inovelli_lzw36( assert args["value"] == 1 client.async_send_command.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( "fan", "turn_on", {"entity_id": entity_id, "preset_mode": "wheeze"}, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(client.async_send_command.call_args_list) == 0 @@ -675,13 +676,14 @@ async def test_thermostat_fan( client.async_send_command.reset_mock() # Test setting unknown preset mode - with pytest.raises(ValueError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "Turbo"}, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" client.async_send_command.reset_mock() diff --git a/tests/testing_config/custom_components/test/fan.py b/tests/testing_config/custom_components/test/fan.py new file mode 100644 index 00000000000..133f372f4fa --- /dev/null +++ b/tests/testing_config/custom_components/test/fan.py @@ -0,0 +1,64 @@ +"""Provide a mock fan platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from tests.common import MockEntity + +ENTITIES = {} + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + {} + if empty + else { + "support_preset_mode": MockFan( + name="Support fan with preset_mode support", + supported_features=FanEntityFeature.PRESET_MODE, + unique_id="unique_support_preset_mode", + preset_modes=["auto", "eco"], + ) + } + ) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +): + """Return mock entities.""" + async_add_entities_callback(list(ENTITIES.values())) + + +class MockFan(MockEntity, FanEntity): + """Mock Fan class.""" + + @property + def preset_mode(self) -> str | None: + """Return preset mode.""" + return self._handle("preset_mode") + + @property + def preset_modes(self) -> list[str] | None: + """Return preset mode.""" + return self._handle("preset_modes") + + @property + def supported_features(self): + """Return the class of this fan.""" + return self._handle("supported_features") + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + self._attr_preset_mode = preset_mode + await self.async_update_ha_state() From 0a1396820929cfc1ab445365ffdce2228fadf265 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Nov 2023 14:17:15 +0100 Subject: [PATCH 859/982] Improve devialet coordinator typing (#104707) --- homeassistant/components/devialet/coordinator.py | 4 ++-- homeassistant/components/devialet/media_player.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py index f0ee47150cc..9e1eada7183 100644 --- a/homeassistant/components/devialet/coordinator.py +++ b/homeassistant/components/devialet/coordinator.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -class DevialetCoordinator(DataUpdateCoordinator): +class DevialetCoordinator(DataUpdateCoordinator[None]): """Devialet update coordinator.""" def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: @@ -27,6 +27,6 @@ class DevialetCoordinator(DataUpdateCoordinator): ) self.client = client - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" await self.client.async_update() diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index 75fc420fa87..a79a82e6f60 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -46,13 +46,15 @@ async def async_setup_entry( async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) -class DevialetMediaPlayerEntity(CoordinatorEntity, MediaPlayerEntity): +class DevialetMediaPlayerEntity( + CoordinatorEntity[DevialetCoordinator], MediaPlayerEntity +): """Devialet media player.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator, entry: ConfigEntry) -> None: + def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None: """Initialize the Devialet device.""" self.coordinator = coordinator super().__init__(coordinator) From 09d7679818bffeba4a999380722e281315afe3cf Mon Sep 17 00:00:00 2001 From: stegm Date: Wed, 29 Nov 2023 14:24:09 +0100 Subject: [PATCH 860/982] Add new sensors of Kostal Plenticore integration (#103802) --- .../components/kostal_plenticore/helper.py | 24 ++++-- .../kostal_plenticore/manifest.json | 2 +- .../components/kostal_plenticore/sensor.py | 79 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/kostal_plenticore/conftest.py | 24 +++--- .../kostal_plenticore/test_config_flow.py | 28 ++++++- .../kostal_plenticore/test_diagnostics.py | 22 +++--- .../kostal_plenticore/test_helper.py | 34 ++++++-- .../kostal_plenticore/test_number.py | 68 +++++++--------- .../kostal_plenticore/test_select.py | 20 ++++- 11 files changed, 221 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 1c495ac9db9..adb1bfb6f09 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,13 +3,18 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging from typing import Any, TypeVar, cast from aiohttp.client_exceptions import ClientError -from pykoplenti import ApiClient, ApiException, AuthenticationException +from pykoplenti import ( + ApiClient, + ApiException, + AuthenticationException, + ExtendedApiClient, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -51,7 +56,9 @@ class Plenticore: async def async_setup(self) -> bool: """Set up Plenticore API client.""" - self._client = ApiClient(async_get_clientsession(self.hass), host=self.host) + self._client = ExtendedApiClient( + async_get_clientsession(self.hass), host=self.host + ) try: await self._client.login(self.config_entry.data[CONF_PASSWORD]) except AuthenticationException as err: @@ -124,7 +131,7 @@ class DataUpdateCoordinatorMixin: async def async_read_data( self, module_id: str, data_id: str - ) -> dict[str, dict[str, str]] | None: + ) -> Mapping[str, Mapping[str, str]] | None: """Read data from Plenticore.""" if (client := self._plenticore.client) is None: return None @@ -190,7 +197,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): class ProcessDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]] + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] ): """Implementation of PlenticoreUpdateCoordinator for process data.""" @@ -206,18 +213,19 @@ class ProcessDataUpdateCoordinator( return { module_id: { process_data.id: process_data.value - for process_data in fetched_data[module_id] + for process_data in fetched_data[module_id].values() } for module_id in fetched_data } class SettingDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]], DataUpdateCoordinatorMixin + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]], + DataUpdateCoordinatorMixin, ): """Implementation of PlenticoreUpdateCoordinator for settings data.""" - async def _async_update_data(self) -> dict[str, dict[str, str]]: + async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: client = self._plenticore.client if not self._fetch or client is None: diff --git a/homeassistant/components/kostal_plenticore/manifest.json b/homeassistant/components/kostal_plenticore/manifest.json index 95f4a194977..d65368e7ee4 100644 --- a/homeassistant/components/kostal_plenticore/manifest.json +++ b/homeassistant/components/kostal_plenticore/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/kostal_plenticore", "iot_class": "local_polling", "loggers": ["kostal"], - "requirements": ["pykoplenti==1.0.0"] + "requirements": ["pykoplenti==1.2.2"] } diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f7bad638df4..ce18867511d 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -649,6 +649,39 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Day", + name="Battery Discharge Day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Month", + name="Battery Discharge Month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Year", + name="Battery Discharge Year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Total", + name="Battery Discharge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), PlenticoreSensorEntityDescription( module_id="scb:statistic:EnergyFlow", key="Statistic:EnergyDischargeGrid:Day", @@ -682,6 +715,52 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="pv_P", + name="Sum power of all PV DC inputs", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=True, + state_class=SensorStateClass.MEASUREMENT, + formatter="format_round", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Total", + name="Energy to Grid Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Year", + name="Energy to Grid Year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Month", + name="Energy to Grid Month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Day", + name="Energy to Grid Day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), ] diff --git a/requirements_all.txt b/requirements_all.txt index f76a27f0d8e..2f88508ce0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1835,7 +1835,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.0.0 +pykoplenti==1.2.2 # homeassistant.components.kraken pykrakenapi==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64cd02ec6c4..6fb4dda4222 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1388,7 +1388,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.0.0 +pykoplenti==1.2.2 # homeassistant.components.kraken pykrakenapi==0.1.8 diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index 814a46f4a25..a83d9fd5e17 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -49,24 +49,20 @@ def mock_plenticore() -> Generator[Plenticore, None, None]: plenticore.client.get_version = AsyncMock() plenticore.client.get_version.return_value = VersionData( - { - "api_version": "0.2.0", - "hostname": "scb", - "name": "PUCK RESTful API", - "sw_version": "01.16.05025", - } + api_version="0.2.0", + hostname="scb", + name="PUCK RESTful API", + sw_version="01.16.05025", ) plenticore.client.get_me = AsyncMock() plenticore.client.get_me.return_value = MeData( - { - "locked": False, - "active": True, - "authenticated": True, - "permissions": [], - "anonymous": False, - "role": "USER", - } + locked=False, + active=True, + authenticated=True, + permissions=[], + anonymous=False, + role="USER", ) plenticore.client.get_process_data = AsyncMock() diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 41facfe9c26..8bfe227bfdf 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -54,7 +54,19 @@ async def test_form_g1( # mock of the context manager instance mock_apiclient.login = AsyncMock() mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ), + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" @@ -108,7 +120,19 @@ async def test_form_g2( # mock of the context manager instance mock_apiclient.login = AsyncMock() mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ), + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index d6a57648400..87c8c0e26a8 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -26,15 +26,13 @@ async def test_entry_diagnostics( mock_plenticore.client.get_settings.return_value = { "devices:local": [ SettingsData( - { - "id": "Battery:MinSoc", - "unit": "%", - "default": "None", - "min": 5, - "max": 100, - "type": "byte", - "access": "readwrite", - } + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", ) ] } @@ -56,12 +54,12 @@ async def test_entry_diagnostics( "disabled_by": None, }, "client": { - "version": "Version(api_version=0.2.0, hostname=scb, name=PUCK RESTful API, sw_version=01.16.05025)", - "me": "Me(locked=False, active=True, authenticated=True, permissions=[], anonymous=False, role=USER)", + "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", + "me": "is_locked=False is_active=True is_authenticated=True permissions=[] is_anonymous=False role='USER'", "available_process_data": {"devices:local": ["HomeGrid_P", "HomePv_P"]}, "available_settings_data": { "devices:local": [ - "SettingsData(id=Battery:MinSoc, unit=%, default=None, min=5, max=100,type=byte, access=readwrite)" + "min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'" ] }, }, diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py index 61df222fd9e..93550405897 100644 --- a/tests/components/kostal_plenticore/test_helper.py +++ b/tests/components/kostal_plenticore/test_helper.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from pykoplenti import ApiClient, SettingsData +from pykoplenti import ApiClient, ExtendedApiClient, SettingsData import pytest from homeassistant.components.kostal_plenticore.const import DOMAIN @@ -17,10 +17,10 @@ from tests.common import MockConfigEntry def mock_apiclient() -> Generator[ApiClient, None, None]: """Return a mocked ApiClient class.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ApiClient", + "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", autospec=True, ) as mock_api_class: - apiclient = MagicMock(spec=ApiClient) + apiclient = MagicMock(spec=ExtendedApiClient) apiclient.__aenter__.return_value = apiclient apiclient.__aexit__ = AsyncMock() mock_api_class.return_value = apiclient @@ -34,7 +34,19 @@ async def test_plenticore_async_setup_g1( ) -> None: """Tests the async_setup() method of the Plenticore class for G1 models.""" mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ) + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" @@ -74,7 +86,19 @@ async def test_plenticore_async_setup_g2( ) -> None: """Tests the async_setup() method of the Plenticore class for G2 models.""" mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ) + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index dd5ba7127a8..fc7d9f213fe 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -23,9 +23,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture def mock_plenticore_client() -> Generator[ApiClient, None, None]: - """Return a patched ApiClient.""" + """Return a patched ExtendedApiClient.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ApiClient", + "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", autospec=True, ) as plenticore_client_class: yield plenticore_client_class.return_value @@ -41,39 +41,33 @@ def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: mock_plenticore_client.get_settings.return_value = { "devices:local": [ SettingsData( - { - "default": None, - "min": 5, - "max": 100, - "access": "readwrite", - "unit": "%", - "type": "byte", - "id": "Battery:MinSoc", - } + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", ), SettingsData( - { - "default": None, - "min": 50, - "max": 38000, - "access": "readwrite", - "unit": "W", - "type": "byte", - "id": "Battery:MinHomeComsumption", - } + min="50", + max="38000", + default=None, + access="readwrite", + unit="W", + id="Battery:MinHomeComsumption", + type="byte", ), ], "scb:network": [ SettingsData( - { - "min": "1", - "default": None, - "access": "readwrite", - "unit": None, - "id": "Hostname", - "type": "string", - "max": "63", - } + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", ) ], } @@ -129,15 +123,13 @@ async def test_setup_no_entries( mock_plenticore_client.get_settings.return_value = { "scb:network": [ SettingsData( - { - "min": "1", - "default": None, - "access": "readwrite", - "unit": None, - "id": "Hostname", - "type": "string", - "max": "63", - } + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", ) ], } diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index 682e8f72ac8..9af2589af9b 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -18,8 +18,24 @@ async def test_select_battery_charging_usage_available( mock_plenticore.client.get_settings.return_value = { "devices:local": [ - SettingsData({"id": "Battery:SmartBatteryControl:Enable"}), - SettingsData({"id": "Battery:TimeControl:Enable"}), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:SmartBatteryControl:Enable", + type="string", + ), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:TimeControl:Enable", + type="string", + ), ] } From e884933dbdd5088eb4bd91a4a6e8f16df6e87aca Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 29 Nov 2023 14:46:19 +0100 Subject: [PATCH 861/982] Remove rest api service call timeout (#104709) --- homeassistant/components/api/__init__.py | 13 ++++------ tests/components/api/test_init.py | 32 ++++++++++++++++++------ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 6bb3cc34050..7e4966e2b0d 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -390,17 +390,14 @@ class APIDomainServicesView(HomeAssistantView): ) try: - async with timeout(SERVICE_WAIT_TIMEOUT): - # shield the service call from cancellation on connection drop - await shield( - hass.services.async_call( - domain, service, data, blocking=True, context=context - ) + # shield the service call from cancellation on connection drop + await shield( + hass.services.async_call( + domain, service, data, blocking=True, context=context ) + ) except (vol.Invalid, ServiceNotFound) as ex: raise HTTPBadRequest() from ex - except TimeoutError: - pass finally: cancel_listen() diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index f97b55c3ede..08cb77b4559 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -1,9 +1,10 @@ """The tests for the Home Assistant API component.""" +import asyncio from http import HTTPStatus import json from unittest.mock import patch -from aiohttp import web +from aiohttp import ServerDisconnectedError, web from aiohttp.test_utils import TestClient import pytest import voluptuous as vol @@ -352,26 +353,41 @@ async def test_api_call_service_with_data( assert state["attributes"] == {"data": 1} -async def test_api_call_service_timeout( +async def test_api_call_service_client_closed( hass: HomeAssistant, mock_api_client: TestClient ) -> None: - """Test if the API does not fail on long running services.""" + """Test that services keep running if client is closed.""" test_value = [] fut = hass.loop.create_future() + service_call_started = asyncio.Event() async def listener(service_call): """Wait and return after mock_api_client.post finishes.""" + service_call_started.set() value = await fut test_value.append(value) hass.services.async_register("test_domain", "test_service", listener) - with patch("homeassistant.components.api.SERVICE_WAIT_TIMEOUT", 0): - await mock_api_client.post("/api/services/test_domain/test_service") - assert len(test_value) == 0 - fut.set_result(1) - await hass.async_block_till_done() + api_task = hass.async_create_task( + mock_api_client.post("/api/services/test_domain/test_service") + ) + + await service_call_started.wait() + + assert len(test_value) == 0 + + await mock_api_client.close() + + assert len(test_value) == 0 + assert api_task.done() + + with pytest.raises(ServerDisconnectedError): + await api_task + + fut.set_result(1) + await hass.async_block_till_done() assert len(test_value) == 1 assert test_value[0] == 1 From 8f2e69fdb7d586f5d1ef873a04c0945e8a4e85c5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 15:18:25 +0100 Subject: [PATCH 862/982] Revert "Update stookwijzer api to atlas leefomgeving (#103323)" (#104705) --- homeassistant/components/stookwijzer/const.py | 6 +++--- homeassistant/components/stookwijzer/diagnostics.py | 9 ++++----- homeassistant/components/stookwijzer/manifest.json | 2 +- homeassistant/components/stookwijzer/sensor.py | 6 +++--- homeassistant/components/stookwijzer/strings.json | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 16 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index d1ac46148a7..1a125da6a6b 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -10,6 +10,6 @@ LOGGER = logging.getLogger(__package__) class StookwijzerState(StrEnum): """Stookwijzer states for sensor entity.""" - CODE_YELLOW = "code_yellow" - CODE_ORANGE = "code_orange" - CODE_RED = "code_red" + BLUE = "blauw" + ORANGE = "oranje" + RED = "rood" diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 85996bb6394..e29606cb191 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -24,9 +24,8 @@ async def async_get_config_entry_diagnostics( return { "state": client.state, "last_updated": last_updated, - "alert": client.alert, - "air_quality_index": client.lki, - "windspeed_bft": client.windspeed_bft, - "windspeed_ms": client.windspeed_ms, - "forecast": client.forecast, + "lqi": client.lqi, + "windspeed": client.windspeed, + "weather": client.weather, + "concentrations": client.concentrations, } diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 91504ef923f..dbf902b1e1e 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.4.2"] + "requirements": ["stookwijzer==1.3.0"] } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index e8d03499a8e..312f8bdd02d 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( class StookwijzerSensor(SensorEntity): """Defines a Stookwijzer binary sensor.""" - _attr_attribution = "Data provided by atlasleefomgeving.nl" + _attr_attribution = "Data provided by stookwijzer.nu" _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True _attr_name = None @@ -43,9 +43,9 @@ class StookwijzerSensor(SensorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{entry.entry_id}")}, name="Stookwijzer", - manufacturer="Atlas Leefomgeving", + manufacturer="stookwijzer.nu", entry_type=DeviceEntryType.SERVICE, - configuration_url="https://www.atlasleefomgeving.nl/stookwijzer", + configuration_url="https://www.stookwijzer.nu", ) def update(self) -> None: diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 62006f878c8..549673165ec 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -13,9 +13,9 @@ "sensor": { "stookwijzer": { "state": { - "code_yellow": "Code yellow", - "code_orange": "Code orange", - "code_red": "Code red" + "blauw": "Blue", + "oranje": "Orange", + "rood": "Red" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 2f88508ce0b..1e570fecc98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2522,7 +2522,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.4.2 +stookwijzer==1.3.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fb4dda4222..2ff6f894285 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1886,7 +1886,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.4.2 +stookwijzer==1.3.0 # homeassistant.components.huawei_lte # homeassistant.components.solaredge From 61d82ae9ab173e0be186b4b361f3144278eb217c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Nov 2023 15:20:21 +0100 Subject: [PATCH 863/982] Tweak dockerfile generation (#104717) --- script/hassfest/docker.py | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 1849e8e7ec8..3bd44736038 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -4,14 +4,14 @@ from homeassistant.util import executor, thread from .model import Config, Integration -DOCKERFILE_TEMPLATE = """# Automatically generated by hassfest. +DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker ARG BUILD_FROM FROM ${{BUILD_FROM}} # Synchronize with homeassistant/core.py:async_stop -ENV \\ +ENV \ S6_SERVICES_GRACETIME={timeout} ARG QEMU_CPU @@ -21,33 +21,33 @@ WORKDIR /usr/src ## Setup Home Assistant Core dependencies COPY requirements.txt homeassistant/ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ -RUN \\ - pip3 install \\ - --only-binary=:all: \\ +RUN \ + pip3 install \ + --only-binary=:all: \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ -RUN \\ - if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \\ - pip3 install homeassistant/home_assistant_frontend-*.whl; \\ - fi \\ - && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \\ - pip3 install homeassistant/home_assistant_intents-*.whl; \\ - fi \\ - && \\ - LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \\ - MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \\ - pip3 install \\ - --only-binary=:all: \\ +RUN \ + if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \ + pip3 install homeassistant/home_assistant_frontend-*.whl; \ + fi \ + && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ + pip3 install homeassistant/home_assistant_intents-*.whl; \ + fi \ + && \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ + pip3 install \ + --only-binary=:all: \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core COPY . homeassistant/ -RUN \\ - pip3 install \\ - --only-binary=:all: \\ - -e ./homeassistant \\ - && python3 -m compileall \\ +RUN \ + pip3 install \ + --only-binary=:all: \ + -e ./homeassistant \ + && python3 -m compileall \ homeassistant/homeassistant # Home Assistant S6-Overlay From c6c8bb69700b7107b055de533c2b3a9dfd9a07eb Mon Sep 17 00:00:00 2001 From: Stefan Rado <628587+kroimon@users.noreply.github.com> Date: Wed, 29 Nov 2023 15:20:40 +0100 Subject: [PATCH 864/982] Bump aioesphomeapi to 19.2.1 (#104703) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 26d15da680b..7eca285681d 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==19.2.0", + "aioesphomeapi==19.2.1", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1e570fecc98..a52aa448d7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.0 +aioesphomeapi==19.2.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ff6f894285..a49238e84d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.0 +aioesphomeapi==19.2.1 # homeassistant.components.flo aioflo==2021.11.0 From 36eb858d0aecdf88c1f5c05b825d8d737b1a24ef Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 15:22:21 +0100 Subject: [PATCH 865/982] Rename variable in Epson tests (#104722) --- tests/components/epson/test_media_player.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py index 8d6af04c174..874a12173d6 100644 --- a/tests/components/epson/test_media_player.py +++ b/tests/components/epson/test_media_player.py @@ -30,9 +30,9 @@ async def test_set_unique_id( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.unique_id is None - entity = entity_registry.async_get("media_player.epson") - assert entity - assert entity.unique_id == entry.entry_id + entity_entry = entity_registry.async_get("media_player.epson") + assert entity_entry + assert entity_entry.unique_id == entry.entry_id with patch( "homeassistant.components.epson.Projector.get_power", return_value="01" ), patch( @@ -43,7 +43,7 @@ async def test_set_unique_id( freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() - entity = entity_registry.async_get("media_player.epson") - assert entity - assert entity.unique_id == "123" + entity_entry = entity_registry.async_get("media_player.epson") + assert entity_entry + assert entity_entry.unique_id == "123" assert entry.unique_id == "123" From ba481001c318d301e1a36cb65991de424b3fb251 Mon Sep 17 00:00:00 2001 From: dupondje Date: Wed, 29 Nov 2023 15:41:58 +0100 Subject: [PATCH 866/982] Add support for multiple mbus devices in dsmr (#84097) * Add support for multiple mbus devices in dsmr A dsmr meter can have 4 mbus devices. Support them all and also add support for a water meter on the mbus device. * Apply suggestions from code review Co-authored-by: Jan Bouwhuis * Rewrite old gas sensor to new mbus sensor * No force updates + fix mbus entity unique_id * Remove old gas device * Add additional tests * Fix remarks from last review + move migrated 5b gas meter to new device_id * Fix ruff error * Last fixes --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/dsmr/const.py | 1 + homeassistant/components/dsmr/sensor.py | 199 ++++++-- homeassistant/components/dsmr/strings.json | 3 + tests/components/dsmr/test_mbus_migration.py | 212 +++++++++ tests/components/dsmr/test_sensor.py | 451 ++++++++++++++----- 5 files changed, 720 insertions(+), 146 deletions(-) create mode 100644 tests/components/dsmr/test_mbus_migration.py diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 5e1a54aedc4..ec0623a9ed6 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -29,6 +29,7 @@ DATA_TASK = "task" DEVICE_NAME_ELECTRICITY = "Electricity Meter" DEVICE_NAME_GAS = "Gas Meter" +DEVICE_NAME_WATER = "Water Meter" DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 3dbd446001f..487f996ac1f 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -57,6 +58,7 @@ from .const import ( DEFAULT_TIME_BETWEEN_UPDATE, DEVICE_NAME_ELECTRICITY, DEVICE_NAME_GAS, + DEVICE_NAME_WATER, DOMAIN, DSMR_PROTOCOL, LOGGER, @@ -73,6 +75,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription): dsmr_versions: set[str] | None = None is_gas: bool = False + is_water: bool = False obis_reference: str @@ -374,28 +377,138 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ) -def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription: - """Return correct entity for 5B Gas meter.""" - ref = None - if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS1_METER_READING2 - elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS2_METER_READING2 - elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS3_METER_READING2 - elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS4_METER_READING2 - elif ref is None: - ref = obis_references.BELGIUM_MBUS1_METER_READING2 - return DSMRSensorEntityDescription( - key="belgium_5min_gas_meter_reading", - translation_key="gas_meter_reading", - obis_reference=ref, - dsmr_versions={"5B"}, - is_gas=True, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL_INCREASING, - ) +def create_mbus_entity( + mbus: int, mtype: int, telegram: dict[str, DSMRObject] +) -> DSMRSensorEntityDescription | None: + """Create a new MBUS Entity.""" + if ( + mtype == 3 + and ( + obis_reference := getattr( + obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2" + ) + ) + in telegram + ): + return DSMRSensorEntityDescription( + key=f"mbus{mbus}_gas_reading", + translation_key="gas_meter_reading", + obis_reference=obis_reference, + is_gas=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + if ( + mtype == 7 + and ( + obis_reference := getattr( + obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1" + ) + ) + in telegram + ): + return DSMRSensorEntityDescription( + key=f"mbus{mbus}_water_reading", + translation_key="water_meter_reading", + obis_reference=obis_reference, + is_water=True, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + return None + + +def device_class_and_uom( + telegram: dict[str, DSMRObject], + entity_description: DSMRSensorEntityDescription, +) -> tuple[SensorDeviceClass | None, str | None]: + """Get native unit of measurement from telegram,.""" + dsmr_object = telegram[entity_description.obis_reference] + uom: str | None = getattr(dsmr_object, "unit") or None + with suppress(ValueError): + if entity_description.device_class == SensorDeviceClass.GAS and ( + enery_uom := UnitOfEnergy(str(uom)) + ): + return (SensorDeviceClass.ENERGY, enery_uom) + if uom in UNIT_CONVERSION: + return (entity_description.device_class, UNIT_CONVERSION[uom]) + return (entity_description.device_class, uom) + + +def rename_old_gas_to_mbus( + hass: HomeAssistant, entry: ConfigEntry, mbus_device_id: str +) -> None: + """Rename old gas sensor to mbus variant.""" + dev_reg = dr.async_get(hass) + device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + if device_entry_v1 is not None: + device_id = device_entry_v1.id + + ent_reg = er.async_get(hass) + entries = er.async_entries_for_device(ent_reg, device_id) + + for entity in entries: + if entity.unique_id.endswith("belgium_5min_gas_meter_reading"): + try: + ent_reg.async_update_entity( + entity.entity_id, + new_unique_id=mbus_device_id, + device_id=mbus_device_id, + ) + except ValueError: + LOGGER.warning( + "Skip migration of %s because it already exists", + entity.entity_id, + ) + else: + LOGGER.info( + "Migrated entity %s from unique id %s to %s", + entity.entity_id, + entity.unique_id, + mbus_device_id, + ) + # Cleanup old device + dev_entities = er.async_entries_for_device( + ent_reg, device_id, include_disabled_entities=True + ) + if not dev_entities: + dev_reg.async_remove_device(device_id) + + +def create_mbus_entities( + hass: HomeAssistant, telegram: dict[str, DSMRObject], entry: ConfigEntry +) -> list[DSMREntity]: + """Create MBUS Entities.""" + entities = [] + for idx in range(1, 5): + if ( + device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE") + ) not in telegram: + continue + if (type_ := int(telegram[device_type].value)) not in (3, 7): + continue + if ( + identifier := getattr( + obis_references, + f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER", + ) + ) in telegram: + serial_ = telegram[identifier].value + rename_old_gas_to_mbus(hass, entry, serial_) + else: + serial_ = "" + if description := create_mbus_entity(idx, type_, telegram): + entities.append( + DSMREntity( + description, + entry, + telegram, + *device_class_and_uom(telegram, description), # type: ignore[arg-type] + serial_, + idx, + ) + ) + return entities async def async_setup_entry( @@ -415,25 +528,10 @@ async def async_setup_entry( add_entities_handler() add_entities_handler = None - def device_class_and_uom( - telegram: dict[str, DSMRObject], - entity_description: DSMRSensorEntityDescription, - ) -> tuple[SensorDeviceClass | None, str | None]: - """Get native unit of measurement from telegram,.""" - dsmr_object = telegram[entity_description.obis_reference] - uom: str | None = getattr(dsmr_object, "unit") or None - with suppress(ValueError): - if entity_description.device_class == SensorDeviceClass.GAS and ( - enery_uom := UnitOfEnergy(str(uom)) - ): - return (SensorDeviceClass.ENERGY, enery_uom) - if uom in UNIT_CONVERSION: - return (entity_description.device_class, UNIT_CONVERSION[uom]) - return (entity_description.device_class, uom) - - all_sensors = SENSORS if dsmr_version == "5B": - all_sensors += (add_gas_sensor_5B(telegram),) + mbus_entities = create_mbus_entities(hass, telegram, entry) + for mbus_entity in mbus_entities: + entities.append(mbus_entity) entities.extend( [ @@ -443,7 +541,7 @@ async def async_setup_entry( telegram, *device_class_and_uom(telegram, description), # type: ignore[arg-type] ) - for description in all_sensors + for description in SENSORS if ( description.dsmr_versions is None or dsmr_version in description.dsmr_versions @@ -618,6 +716,8 @@ class DSMREntity(SensorEntity): telegram: dict[str, DSMRObject], device_class: SensorDeviceClass, native_unit_of_measurement: str | None, + serial_id: str = "", + mbus_id: int = 0, ) -> None: """Initialize entity.""" self.entity_description = entity_description @@ -629,8 +729,15 @@ class DSMREntity(SensorEntity): device_serial = entry.data[CONF_SERIAL_ID] device_name = DEVICE_NAME_ELECTRICITY if entity_description.is_gas: - device_serial = entry.data[CONF_SERIAL_ID_GAS] + if serial_id: + device_serial = serial_id + else: + device_serial = entry.data[CONF_SERIAL_ID_GAS] device_name = DEVICE_NAME_GAS + if entity_description.is_water: + if serial_id: + device_serial = serial_id + device_name = DEVICE_NAME_WATER if device_serial is None: device_serial = entry.entry_id @@ -638,7 +745,13 @@ class DSMREntity(SensorEntity): identifiers={(DOMAIN, device_serial)}, name=device_name, ) - self._attr_unique_id = f"{device_serial}_{entity_description.key}" + if mbus_id != 0: + if serial_id: + self._attr_unique_id = f"{device_serial}" + else: + self._attr_unique_id = f"{device_serial}_{mbus_id}" + else: + self._attr_unique_id = f"{device_serial}_{entity_description.key}" @callback def update_data(self, telegram: dict[str, DSMRObject] | None) -> None: diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5f0568e2905..055c0c41264 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -147,6 +147,9 @@ }, "voltage_swell_l3_count": { "name": "Voltage swells phase L3" + }, + "water_meter_reading": { + "name": "Water consumption" } } }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py new file mode 100644 index 00000000000..493fd93259f --- /dev/null +++ b/tests/components/dsmr/test_mbus_migration.py @@ -0,0 +1,212 @@ +"""Tests for the DSMR integration.""" +import datetime +from decimal import Decimal + +from homeassistant.components.dsmr.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_migrate_gas_to_mbus( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: + """Test migration of unique_id.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="/dev/ttyUSB0", + data={ + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "reconnect_interval": 30, + "serial_id": "1234", + "serial_id_gas": "37464C4F32313139303333373331", + }, + options={ + "time_between_update": 0, + }, + ) + + mock_entry.add_to_hass(hass) + + old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" + + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, mock_entry.entry_id)}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device.id, + unique_id=old_unique_id, + config_entry=mock_entry, + ) + assert entity.unique_id == old_unique_id + await hass.async_block_till_done() + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + } + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert not dev_entities + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + is None + ) + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331" + ) + == "sensor.gas_meter_reading" + ) + + +async def test_migrate_gas_to_mbus_exists( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: + """Test migration of unique_id.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="/dev/ttyUSB0", + data={ + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "reconnect_interval": 30, + "serial_id": "1234", + "serial_id_gas": "37464C4F32313139303333373331", + }, + options={ + "time_between_update": 0, + }, + ) + + mock_entry.add_to_hass(hass) + + old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" + + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, mock_entry.entry_id)}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device.id, + unique_id=old_unique_id, + config_entry=mock_entry, + ) + assert entity.unique_id == old_unique_id + + device2 = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, "37464C4F32313139303333373331")}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading_alt", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device2.id, + unique_id="37464C4F32313139303333373331", + config_entry=mock_entry, + ) + await hass.async_block_till_done() + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + } + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + == "sensor.gas_meter_reading" + ) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 1895dd15dd1..1b7f8efb201 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -8,21 +8,8 @@ import asyncio import datetime from decimal import Decimal from itertools import chain, repeat -from typing import Literal from unittest.mock import DEFAULT, MagicMock -from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS4_METER_READING2, -) -import pytest - from homeassistant import config_entries from homeassistant.components.sensor import ( ATTR_OPTIONS, @@ -145,8 +132,8 @@ async def test_default_setup( # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # ensure entities have new state value after incoming telegram power_consumption = hass.states.get("sensor.electricity_meter_power_consumption") @@ -495,10 +482,18 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No from dsmr_parser.obis_references import ( BELGIUM_CURRENT_AVERAGE_DEMAND, BELGIUM_MAXIMUM_DEMAND_MONTH, + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING1, ELECTRICITY_ACTIVE_TARIFF, ) from dsmr_parser.objects import CosemObject, MBusObject @@ -509,41 +504,13 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "precision": 4, "reconnect_interval": 30, "serial_id": "1234", - "serial_id_gas": "5678", + "serial_id_gas": None, } entry_options = { "time_between_update": 0, } telegram = { - BELGIUM_MBUS1_METER_READING2: MBusObject( - BELGIUM_MBUS1_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, - ], - ), - BELGIUM_MBUS2_METER_READING2: MBusObject( - BELGIUM_MBUS2_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, - ], - ), - BELGIUM_MBUS3_METER_READING2: MBusObject( - BELGIUM_MBUS3_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, - ], - ), - BELGIUM_MBUS4_METER_READING2: MBusObject( - BELGIUM_MBUS4_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(745.698), "unit": "m3"}, - ], - ), BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( BELGIUM_CURRENT_AVERAGE_DEMAND, [{"value": Decimal(1.75), "unit": "kW"}], @@ -555,6 +522,62 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No {"value": Decimal(4.11), "unit": "kW"}, ], ), + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS2_METER_READING1: MBusObject( + BELGIUM_MBUS2_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(678.695), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], + ), + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(13.13), "unit": "m3"}, + ], + ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), @@ -600,7 +623,7 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No assert max_demand.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.KILO_WATT assert max_demand.attributes.get(ATTR_STATE_CLASS) is None - # check if gas consumption is parsed correctly + # check if gas consumption mbus1 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS @@ -613,48 +636,69 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No == UnitOfVolume.CUBIC_METERS ) + # check if water usage mbus2 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "678.695" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) -@pytest.mark.parametrize( - ("key1", "key2", "key3", "gas_value"), - [ - ( - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_METER_READING1, - "745.696", - ), - ( - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.695", - ), - ( - BELGIUM_MBUS4_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING1, - "745.695", - ), - ( - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.697", - ), - ], -) -async def test_belgian_meter_alt( - hass: HomeAssistant, - dsmr_connection_fixture, - key1: Literal, - key2: Literal, - key3: Literal, - gas_value: str, -) -> None: + # check if gas consumption mbus1 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption.state == "12.12" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if water usage mbus2 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption.state == "13.13" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + +async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.objects import MBusObject + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING1, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject entry_data = { "port": "/dev/ttyUSB0", @@ -662,32 +706,67 @@ async def test_belgian_meter_alt( "precision": 4, "reconnect_interval": 30, "serial_id": "1234", - "serial_id_gas": "5678", + "serial_id_gas": None, } entry_options = { "time_between_update": 0, } telegram = { - key1: MBusObject( - key1, - [ - {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, - ], + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "007", "unit": ""}] ), - key2: MBusObject( - key2, - [ - {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, - ], + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - key3: MBusObject( - key3, + BELGIUM_MBUS1_METER_READING1: MBusObject( + BELGIUM_MBUS1_METER_READING1, [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, + {"value": Decimal(123.456), "unit": "m3"}, + ], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS2_METER_READING2: MBusObject( + BELGIUM_MBUS2_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(678.901), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING1: MBusObject( + BELGIUM_MBUS3_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], + ), + BELGIUM_MBUS4_METER_READING2: MBusObject( + BELGIUM_MBUS4_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, ], ), } @@ -709,9 +788,24 @@ async def test_belgian_meter_alt( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() - # check if gas consumption is parsed correctly + # check if water usage mbus1 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "123.456" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus2 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == gas_value + assert gas_consumption.state == "678.901" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) @@ -722,6 +816,157 @@ async def test_belgian_meter_alt( == UnitOfVolume.CUBIC_METERS ) + # check if water usage mbus3 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption.state == "12.12" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus4 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption.state == "13.13" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + +async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) -> None: + """Test if Belgian meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_METER_READING1, + ELECTRICITY_ACTIVE_TARIFF, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "reconnect_interval": 30, + "serial_id": "1234", + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0003", "unit": ""}] + ), + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "006", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, + ], + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + # tariff should be translated in human readable and have no unit + active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") + assert active_tariff.state == "unknown" + + # check if gas consumption mbus2 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") + assert gas_consumption is None + + # check if water usage mbus3 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption is None + + # check if gas consumption mbus4 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption is None + + # check if gas consumption mbus4 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "13.13" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" From 608f4f7c5250ac0838358bd636552190290de64c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 09:13:54 -0600 Subject: [PATCH 867/982] Bump aiohttp to 3.9.1 (#104176) --- homeassistant/components/hassio/http.py | 4 ++++ homeassistant/components/hassio/ingress.py | 2 +- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/package_constraints.txt | 3 +-- pyproject.toml | 3 +-- requirements.txt | 3 +-- tests/components/generic/test_camera.py | 16 ++++------------ tests/util/test_aiohttp.py | 22 +++++----------------- 8 files changed, 18 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 419d80484cf..9d72d5842fd 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -6,6 +6,7 @@ from http import HTTPStatus import logging import os import re +from typing import TYPE_CHECKING from urllib.parse import quote, unquote import aiohttp @@ -156,6 +157,9 @@ class HassIOView(HomeAssistantView): # _stored_content_type is only computed once `content_type` is accessed if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary + if TYPE_CHECKING: + # pylint: disable-next=protected-access + assert isinstance(request._stored_content_type, str) # pylint: disable-next=protected-access headers[CONTENT_TYPE] = request._stored_content_type diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index b29f80ff2b3..7da6f044db0 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -172,7 +172,7 @@ class HassIOIngress(HomeAssistantView): content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED) # Avoid parsing content_type in simple cases for better performance if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): - content_type = (maybe_content_type.partition(";"))[0].strip() + content_type: str = (maybe_content_type.partition(";"))[0].strip() else: content_type = result.content_type # Simple request diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index fba9bb647dd..74527a5922f 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -298,7 +298,7 @@ def _async_get_connector( return connectors[connector_key] if verify_ssl: - ssl_context: bool | SSLContext = ssl_util.get_default_context() + ssl_context: SSLContext = ssl_util.get_default_context() else: ssl_context = ssl_util.get_default_no_verify_context() diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4aa2a4ea013..73f12f8db90 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,8 +3,7 @@ aiodiscover==1.5.1 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 -aiohttp==3.8.5;python_version<'3.12' -aiohttp==3.9.0;python_version>='3.12' +aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.2 diff --git a/pyproject.toml b/pyproject.toml index b05f5f5f9dc..71e58bf2177 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.9.0;python_version>='3.12'", - "aiohttp==3.8.5;python_version<'3.12'", + "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.1.1", diff --git a/requirements.txt b/requirements.txt index f26c1a84e81..aa9a0ab0e5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.9.0;python_version>='3.12' -aiohttp==3.8.5;python_version<'3.12' +aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index aecfcbc29c1..8bfd0a66dd5 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,7 +1,6 @@ """The tests for generic camera component.""" import asyncio from http import HTTPStatus -import sys from unittest.mock import patch import aiohttp @@ -164,17 +163,10 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "5") - # TODO: Remove version check with aiohttp 3.9.0 - if sys.version_info >= (3, 12): - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "asyncio.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") - else: - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "async_timeout.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "asyncio.timeout", side_effect=asyncio.TimeoutError() + ): + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 1 assert resp.status == HTTPStatus.OK diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index bfdc3c3e949..ada0269ac0e 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -1,5 +1,4 @@ """Test aiohttp request helper.""" -import sys from aiohttp import web @@ -50,22 +49,11 @@ def test_serialize_text() -> None: def test_serialize_body_str() -> None: """Test serializing a response with a str as body.""" response = web.Response(status=201, body="Hello") - # TODO: Remove version check with aiohttp 3.9.0 - if sys.version_info >= (3, 12): - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": {"Content-Type": "text/plain; charset=utf-8"}, - } - else: - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": { - "Content-Length": "5", - "Content-Type": "text/plain; charset=utf-8", - }, - } + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {"Content-Type": "text/plain; charset=utf-8"}, + } def test_serialize_body_None() -> None: From e2bab699b599a1198af787db14fdb8251304e90a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 29 Nov 2023 16:24:30 +0100 Subject: [PATCH 868/982] Avoid double refresh when adding entities in bsblan (#104647) --- homeassistant/components/bsblan/climate.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 39eab6e7e0a..609d5ab6e83 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -60,8 +60,7 @@ async def async_setup_entry( data.static, entry, ) - ], - True, + ] ) From 4628b036776649dbe1dacea3a908be817eb8d529 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Nov 2023 16:29:20 +0100 Subject: [PATCH 869/982] Update frontend to 20231129.1 (#104723) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6a6c557c7b7..7a587d56d74 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231129.0"] + "requirements": ["home-assistant-frontend==20231129.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 73f12f8db90..acb65b6fce8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231129.0 +home-assistant-frontend==20231129.1 home-assistant-intents==2023.11.17 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a52aa448d7d..b3c498a5f12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.0 +home-assistant-frontend==20231129.1 # homeassistant.components.conversation home-assistant-intents==2023.11.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a49238e84d7..8dc991f9a61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.0 +home-assistant-frontend==20231129.1 # homeassistant.components.conversation home-assistant-intents==2023.11.17 From 82264a0d6b30426fd79ad8413f843241ce8a5f23 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 16:29:42 +0100 Subject: [PATCH 870/982] Fix mqtt cover state is open after receiving stopped payload (#104726) --- homeassistant/components/mqtt/cover.py | 6 +++++- tests/components/mqtt/test_cover.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c8da14e67e6..4e8cf0f4129 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -380,7 +380,11 @@ class MqttCover(MqttEntity, CoverEntity): else STATE_OPEN ) else: - state = STATE_CLOSED if self.state == STATE_CLOSING else STATE_OPEN + state = ( + STATE_CLOSED + if self.state in [STATE_CLOSED, STATE_CLOSING] + else STATE_OPEN + ) elif payload == self._config[CONF_STATE_OPENING]: state = STATE_OPENING elif payload == self._config[CONF_STATE_CLOSING]: diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index f3bf92951b0..8db1c89bc40 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -3347,6 +3347,11 @@ async def test_set_state_via_stopped_state_no_position_topic( state = hass.states.get("cover.test") assert state.state == STATE_CLOSED + async_fire_mqtt_message(hass, "state-topic", "STOPPED") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + @pytest.mark.parametrize( "hass_config", From b36ddaa15c5975fbae36e2b4eef9721ef45f1a35 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 16:30:23 +0100 Subject: [PATCH 871/982] Change super class order in Withings Calendar (#104721) --- homeassistant/components/withings/calendar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index 19572682d1a..132f00936f3 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -66,7 +66,7 @@ def get_event_name(category: WorkoutCategory) -> str: class WithingsWorkoutCalendarEntity( - CalendarEntity, WithingsEntity[WithingsWorkoutDataUpdateCoordinator] + WithingsEntity[WithingsWorkoutDataUpdateCoordinator], CalendarEntity ): """A calendar entity.""" From 4bf88b1690f9d720a012b98b49fbbd5b0dd40c13 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 16:42:10 +0100 Subject: [PATCH 872/982] Improve MQTT json light brightness scaling (#104510) * Improve MQTT json light brightness scaling * Revert unrelated changes * Format --- homeassistant/components/mqtt/light/schema_json.py | 7 ++----- tests/components/mqtt/test_light_json.py | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 2a2a262be36..3d2957f153d 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -367,13 +367,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: + scale = self._config[CONF_BRIGHTNESS_SCALE] self._attr_brightness = min( - int( - brightness # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 - ), 255, + round(brightness * 255 / scale), # type: ignore[operator] ) else: _LOGGER.debug( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index b3dd3a9a4e3..82b0b3467f4 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1792,7 +1792,7 @@ async def test_brightness_scale( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 128 + assert state.attributes.get("brightness") == 129 # Test limmiting max brightness async_fire_mqtt_message( @@ -1862,7 +1862,7 @@ async def test_white_scale( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 128 + assert state.attributes.get("brightness") == 129 @pytest.mark.parametrize( From 47426a3ddca7b19e785ddaa6c151148f03f0a862 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 16:56:26 +0100 Subject: [PATCH 873/982] Remove redundant websocket_api exception handler (#104727) --- homeassistant/components/websocket_api/commands.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 5edf5018938..cb90b46e182 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -794,9 +794,6 @@ async def handle_execute_script( translation_placeholders=err.translation_placeholders, ) return - except Exception as exc: # pylint: disable=broad-except - connection.async_handle_exception(msg, exc) - return connection.send_result( msg["id"], { From a894146cee389078d5b04760eadbd0465bf8181c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Nov 2023 11:07:22 -0600 Subject: [PATCH 874/982] Fix TTS streaming for VoIP (#104620) * Use wav instead of raw tts audio in voip * More tests * Use mock TTS dir --- homeassistant/components/voip/voip.py | 29 +++- tests/components/voip/test_voip.py | 218 +++++++++++++++++++++++++- 2 files changed, 239 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 120f2d9559b..74bc94e7dc5 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -5,10 +5,12 @@ import asyncio from collections import deque from collections.abc import AsyncIterable, MutableSequence, Sequence from functools import partial +import io import logging from pathlib import Path import time from typing import TYPE_CHECKING +import wave from voip_utils import ( CallInfo, @@ -285,7 +287,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): ), conversation_id=self._conversation_id, device_id=self.voip_device.device_id, - tts_audio_output="raw", + tts_audio_output="wav", ) if self._pipeline_error: @@ -402,11 +404,32 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): if self.transport is None: return - _extension, audio_bytes = await tts.async_get_media_source_audio( + extension, data = await tts.async_get_media_source_audio( self.hass, media_id, ) + if extension != "wav": + raise ValueError(f"Only WAV audio can be streamed, got {extension}") + + with io.BytesIO(data) as wav_io: + with wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + + if ( + (sample_rate != 16000) + or (sample_width != 2) + or (sample_channels != 1) + ): + raise ValueError( + "Expected rate/width/channels as 16000/2/1," + " got {sample_rate}/{sample_width}/{sample_channels}}" + ) + + audio_bytes = wav_file.readframes(wav_file.getnframes()) + _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) # Time out 1 second after TTS audio should be finished @@ -414,7 +437,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): tts_seconds = tts_samples / RATE async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): - # Assume TTS audio is 16Khz 16-bit mono + # TTS audio is 16Khz 16-bit mono await self._async_send_audio(audio_bytes) except asyncio.TimeoutError as err: _LOGGER.warning("TTS timeout") diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index f82a00087c6..692896c6dfa 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -1,7 +1,9 @@ """Test VoIP protocol.""" import asyncio +import io import time from unittest.mock import AsyncMock, Mock, patch +import wave import pytest @@ -14,6 +16,24 @@ _ONE_SECOND = 16000 * 2 # 16Khz 16-bit _MEDIA_ID = "12345" +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +def _empty_wav() -> bytes: + """Return bytes of an empty WAV file.""" + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + return wav_io.getvalue() + + async def test_pipeline( hass: HomeAssistant, voip_device: VoIPDevice, @@ -72,8 +92,7 @@ async def test_pipeline( media_source_id: str, ) -> tuple[str, bytes]: assert media_source_id == _MEDIA_ID - - return ("mp3", b"") + return ("wav", _empty_wav()) with patch( "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", @@ -266,7 +285,7 @@ async def test_tts_timeout( media_source_id: str, ) -> tuple[str, bytes]: # Should time out immediately - return ("raw", bytes(0)) + return ("wav", _empty_wav()) with patch( "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", @@ -305,8 +324,197 @@ async def test_tts_timeout( done.set() - rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) - rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) + rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to exhaust the audio stream + async with asyncio.timeout(1): + await done.wait() + + +async def test_tts_wrong_extension( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will only stream WAV audio.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should fail because it's not "wav" + return ("mp3", b"") + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(ValueError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to exhaust the audio stream + async with asyncio.timeout(1): + await done.wait() + + +async def test_tts_wrong_wav_format( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will only stream WAV audio with a specific format.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should fail because it's not 16Khz, 16-bit mono + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(2) + + return ("wav", wav_io.getvalue()) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(ValueError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] # silence rtp_protocol.on_chunk(bytes(_ONE_SECOND)) From 2287c45afcef31e28aa51ef42f2392fda9651ad9 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Wed, 29 Nov 2023 18:11:04 +0100 Subject: [PATCH 875/982] Bump bimmer-connected to 0.14.5 (#104715) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/snapshots/test_diagnostics.ambr | 5 ----- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 911a998371e..1ebf52e52ae 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.3"] + "requirements": ["bimmer-connected[china]==0.14.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index b3c498a5f12..0ac2ddbecea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,7 +526,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.3 +bimmer-connected[china]==0.14.5 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8dc991f9a61..9307034b794 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.3 +bimmer-connected[china]==0.14.5 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 32405d93e6b..b3af5bc59b6 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -413,7 +413,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -1288,7 +1287,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -1979,7 +1977,6 @@ 'charging_settings': dict({ }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -2734,7 +2731,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', @@ -5070,7 +5066,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', From dfed10420caf4c3ba1823553196b5a0a601ac5c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 10:24:34 -0700 Subject: [PATCH 876/982] Remove aiohttp enable_compression helper (#104174) --- homeassistant/components/api/__init__.py | 7 ++++--- homeassistant/components/hassio/ingress.py | 4 ++-- homeassistant/components/http/view.py | 4 ++-- homeassistant/helpers/aiohttp_compat.py | 18 +----------------- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 7e4966e2b0d..057e85613fd 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -41,7 +41,6 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions @@ -218,9 +217,11 @@ class APIStatesView(HomeAssistantView): if entity_perm(state.entity_id, "read") ) response = web.Response( - body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON + body=f'[{",".join(states)}]', + content_type=CONTENT_TYPE_JSON, + zlib_executor_size=32768, ) - enable_compression(response) + response.enable_compression() return response diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 7da6f044db0..751e9005809 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -17,7 +17,6 @@ from yarl import URL from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.typing import UNDEFINED from .const import X_HASS_SOURCE, X_INGRESS_PATH @@ -188,11 +187,12 @@ class HassIOIngress(HomeAssistantView): status=result.status, content_type=content_type, body=body, + zlib_executor_size=32768, ) if content_length_int > MIN_COMPRESSED_SIZE and should_compress( content_type or simple_response.content_type ): - enable_compression(simple_response) + simple_response.enable_compression() await simple_response.prepare(request) return simple_response diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 7481381bbc8..1be3d761a3b 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -20,7 +20,6 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, is_callback -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.json import ( find_paths_unserializable_data, json_bytes, @@ -72,8 +71,9 @@ class HomeAssistantView: content_type=CONTENT_TYPE_JSON, status=int(status_code), headers=headers, + zlib_executor_size=32768, ) - enable_compression(response) + response.enable_compression() return response def json_message( diff --git a/homeassistant/helpers/aiohttp_compat.py b/homeassistant/helpers/aiohttp_compat.py index 6e281b659fe..78aad44fa66 100644 --- a/homeassistant/helpers/aiohttp_compat.py +++ b/homeassistant/helpers/aiohttp_compat.py @@ -1,7 +1,7 @@ """Helper to restore old aiohttp behavior.""" from __future__ import annotations -from aiohttp import web, web_protocol, web_server +from aiohttp import web_protocol, web_server class CancelOnDisconnectRequestHandler(web_protocol.RequestHandler): @@ -23,19 +23,3 @@ def restore_original_aiohttp_cancel_behavior() -> None: """ web_protocol.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] web_server.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] - - -def enable_compression(response: web.Response) -> None: - """Enable compression on the response.""" - # - # Set _zlib_executor_size in the constructor once support for - # aiohttp < 3.9.0 is dropped - # - # We want large zlib payloads to be compressed in the executor - # to avoid blocking the event loop. - # - # 32KiB was chosen based on testing in production. - # aiohttp will generate a warning for payloads larger than 1MiB - # - response._zlib_executor_size = 32768 # pylint: disable=protected-access - response.enable_compression() From 38eda9f46e305cddd9bf6690dce50ef4ca975e02 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 29 Nov 2023 18:32:32 +0100 Subject: [PATCH 877/982] Add multiple option to text selector (#104635) Co-authored-by: Robert Resch --- homeassistant/helpers/selector.py | 12 +++++++++--- tests/helpers/test_config_validation.py | 1 + tests/helpers/test_selector.py | 5 +++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index bda2440cfb3..f7ceb4ab812 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1206,6 +1206,7 @@ class TextSelectorConfig(TypedDict, total=False): suffix: str type: TextSelectorType autocomplete: str + multiple: bool class TextSelectorType(StrEnum): @@ -1243,6 +1244,7 @@ class TextSelector(Selector[TextSelectorConfig]): vol.Coerce(TextSelectorType), lambda val: val.value ), vol.Optional("autocomplete"): str, + vol.Optional("multiple", default=False): bool, } ) @@ -1250,10 +1252,14 @@ class TextSelector(Selector[TextSelectorConfig]): """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> str: + def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" - text: str = vol.Schema(str)(data) - return text + if not self.config["multiple"]: + text: str = vol.Schema(str)(data) + return text + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [vol.Schema(str)(val) for val in data] class ThemeSelectorConfig(TypedDict): diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index a9ddd89a0b3..6d1945f2d5f 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -832,6 +832,7 @@ def test_selector_in_serializer() -> None: "selector": { "text": { "multiline": False, + "multiple": False, } } } diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 93c342384fd..c4ad244620b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -602,6 +602,11 @@ def test_object_selector_schema(schema, valid_selections, invalid_selections) -> ({"multiline": True}, (), ()), ({"multiline": False, "type": "email"}, (), ()), ({"prefix": "before", "suffix": "after"}, (), ()), + ( + {"multiple": True}, + (["abc123", "def456"],), + ("abc123", None, ["abc123", None]), + ), ), ) def test_text_selector_schema(schema, valid_selections, invalid_selections) -> None: From 1727c19e0deb1af006dbfe966aaeb208dfeed680 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Nov 2023 18:35:55 +0100 Subject: [PATCH 878/982] Address review comments for Picnic (#104732) --- homeassistant/components/picnic/todo.py | 7 +++---- tests/components/picnic/conftest.py | 20 ++------------------ tests/components/picnic/test_todo.py | 3 ++- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index 47b9685c9ec..fea99f7403d 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -12,6 +12,7 @@ from homeassistant.components.todo import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -31,7 +32,7 @@ async def async_setup_entry( """Set up the Picnic shopping cart todo platform config entry.""" picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] - async_add_entities([PicnicCart(hass, picnic_coordinator, config_entry)]) + async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): @@ -44,7 +45,6 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): def __init__( self, - hass: HomeAssistant, coordinator: PicnicUpdateCoordinator, config_entry: ConfigEntry, ) -> None: @@ -56,7 +56,6 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): manufacturer="Picnic", model=config_entry.unique_id, ) - self.hass = hass self._attr_unique_id = f"{config_entry.unique_id}-cart" @property @@ -87,7 +86,7 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): ) if not product_id: - raise ValueError("No product found or no product ID given") + raise ServiceValidationError("No product found or no product ID given") await self.hass.async_add_executor_job( self.coordinator.picnic_api_client.add_product, product_id, 1 diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index fb6c99f35e9..1ca6413fc42 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -56,31 +56,16 @@ async def init_integration( return mock_config_entry -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next_id() -> int: - nonlocal id - id += 1 - return id - - return next_id - - @pytest.fixture async def get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { "id": id, "type": "todo/item/list", @@ -88,7 +73,6 @@ async def get_items( } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py index a65fb83ca95..cdd30967058 100644 --- a/tests/components/picnic/test_todo.py +++ b/tests/components/picnic/test_todo.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.todo import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .conftest import ENTITY_ID @@ -115,7 +116,7 @@ async def test_create_todo_list_item_not_found( mock_picnic_api.search = Mock() mock_picnic_api.search.return_value = [{"items": []}] - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, "add_item", From 1b048ff388c047a0741bd3a3d1a5677a788943e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 10:40:19 -0700 Subject: [PATCH 879/982] Remove HomeAssistantAccessLogger (#104173) --- .../components/emulated_hue/__init__.py | 3 +-- homeassistant/components/http/__init__.py | 24 +------------------ tests/components/http/test_init.py | 23 +----------------- 3 files changed, 3 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index a98d2c08a48..1ba93da716c 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,7 +6,6 @@ import logging from aiohttp import web import voluptuous as vol -from homeassistant.components.http import HomeAssistantAccessLogger from homeassistant.components.network import async_get_source_ip from homeassistant.const import ( CONF_ENTITIES, @@ -101,7 +100,7 @@ async def start_emulated_hue_bridge( config.advertise_port or config.listen_port, ) - runner = web.AppRunner(app, access_log_class=HomeAssistantAccessLogger) + runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 5a1d182e80c..3ccfa18ef12 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -16,7 +16,6 @@ from aiohttp.http_parser import RawRequestMessage from aiohttp.streams import StreamReader from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection -from aiohttp.web_log import AccessLogger from aiohttp.web_protocol import RequestHandler from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher from aiohttp_zlib_ng import enable_zlib_ng @@ -238,25 +237,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class HomeAssistantAccessLogger(AccessLogger): - """Access logger for Home Assistant that does not log when disabled.""" - - def log( - self, request: web.BaseRequest, response: web.StreamResponse, time: float - ) -> None: - """Log the request. - - The default implementation logs the request to the logger - with the INFO level and than throws it away if the logger - is not enabled for the INFO level. This implementation - does not log the request if the logger is not enabled for - the INFO level. - """ - if not self.logger.isEnabledFor(logging.INFO): - return - super().log(request, response, time) - - class HomeAssistantRequest(web.Request): """Home Assistant request object.""" @@ -540,9 +520,7 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner( - self.app, access_log_class=HomeAssistantAccessLogger - ) + self.runner = web.AppRunner(self.app) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5a5bffe6748..97e39811cd8 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -5,8 +5,7 @@ from http import HTTPStatus from ipaddress import ip_network import logging from pathlib import Path -import time -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest @@ -21,7 +20,6 @@ from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.typing import ClientSessionGenerator @@ -501,22 +499,3 @@ async def test_logging( response = await client.get("/api/states/logging.entity") assert response.status == HTTPStatus.OK assert "GET /api/states/logging.entity" not in caplog.text - - -async def test_hass_access_logger_at_info_level( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test that logging happens at info level.""" - test_logger = logging.getLogger("test.aiohttp.logger") - logger = http.HomeAssistantAccessLogger(test_logger) - mock_request = MagicMock() - response = AiohttpClientMockResponse( - "POST", "http://127.0.0.1", status=HTTPStatus.OK - ) - setattr(response, "body_length", 42) - logger.log(mock_request, response, time.time()) - assert "42" in caplog.text - caplog.clear() - test_logger.setLevel(logging.WARNING) - logger.log(mock_request, response, time.time()) - assert "42" not in caplog.text From e10d58ef3e8d1362fbeb7858bf6cf2b62753a8c9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Nov 2023 11:52:27 -0600 Subject: [PATCH 880/982] Bump intents to 2023.11.29 (#104738) --- homeassistant/components/conversation/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/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index e99334b5c37..2a069d5d92b 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.17"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.29"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index acb65b6fce8..75209de5996 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231129.1 -home-assistant-intents==2023.11.17 +home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0ac2ddbecea..ec0e5f232e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1017,7 +1017,7 @@ holidays==0.36 home-assistant-frontend==20231129.1 # homeassistant.components.conversation -home-assistant-intents==2023.11.17 +home-assistant-intents==2023.11.29 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9307034b794..75947854c7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,7 +804,7 @@ holidays==0.36 home-assistant-frontend==20231129.1 # homeassistant.components.conversation -home-assistant-intents==2023.11.17 +home-assistant-intents==2023.11.29 # homeassistant.components.home_connect homeconnect==0.7.2 From 1fefa936484d80ca7a5eafda411b0bd944039137 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 19:03:58 +0100 Subject: [PATCH 881/982] Use config entry callbacks in Gree (#104740) --- homeassistant/components/gree/__init__.py | 15 ++++----------- homeassistant/components/gree/climate.py | 5 ++--- homeassistant/components/gree/const.py | 1 - homeassistant/components/gree/switch.py | 6 +++--- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index ff3438ed53f..13e93d780b2 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -11,7 +11,6 @@ from homeassistant.helpers.event import async_track_time_interval from .bridge import DiscoveryService from .const import ( COORDINATORS, - DATA_DISCOVERY_INTERVAL, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DISPATCHERS, @@ -29,7 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gree_discovery = DiscoveryService(hass) hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery - hass.data[DOMAIN].setdefault(DISPATCHERS, []) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def _async_scan_update(_=None): @@ -39,8 +37,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Scanning network for Gree devices") await _async_scan_update() - hass.data[DOMAIN][DATA_DISCOVERY_INTERVAL] = async_track_time_interval( - hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + ) ) return True @@ -48,13 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN].get(DISPATCHERS) is not None: - for cleanup in hass.data[DOMAIN][DISPATCHERS]: - cleanup() - - if hass.data[DOMAIN].get(DATA_DISCOVERY_INTERVAL) is not None: - hass.data[DOMAIN].pop(DATA_DISCOVERY_INTERVAL)() - if hass.data.get(DATA_DISCOVERY_SERVICE) is not None: hass.data.pop(DATA_DISCOVERY_SERVICE) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index b14b9cfaba4..ba162173724 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -47,7 +47,6 @@ from .bridge import DeviceDataUpdateCoordinator from .const import ( COORDINATORS, DISPATCH_DEVICE_DISCOVERED, - DISPATCHERS, DOMAIN, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, @@ -88,7 +87,7 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @@ -101,7 +100,7 @@ async def async_setup_entry( for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) - hass.data[DOMAIN][DISPATCHERS].append( + entry.async_on_unload( async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) ) diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index b4df7a1acde..46479210921 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -3,7 +3,6 @@ COORDINATORS = "coordinators" DATA_DISCOVERY_SERVICE = "gree_discovery" -DATA_DISCOVERY_INTERVAL = "gree_discovery_interval" DISCOVERY_SCAN_INTERVAL = 300 DISCOVERY_TIMEOUT = 8 diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 68c11ad6e1f..7916df18abc 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import GreeEntity @@ -102,7 +102,7 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @@ -119,7 +119,7 @@ async def async_setup_entry( for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) - hass.data[DOMAIN][DISPATCHERS].append( + entry.async_on_unload( async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) ) From 50f2c411456b8f53b199d7782328da0c8e8138be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 11:20:22 -0700 Subject: [PATCH 882/982] Avoid db hit and executor job for impossible history queries (#104724) --- homeassistant/components/history/__init__.py | 5 +++-- homeassistant/components/history/helpers.py | 9 +++++++++ homeassistant/components/history/websocket_api.py | 5 +++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index f5b97a7fb13..9eab92dce5c 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -21,7 +21,7 @@ import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN -from .helpers import entities_may_have_state_changes_after +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after CONF_ORDER = "use_include_order" @@ -106,7 +106,8 @@ class HistoryPeriodView(HomeAssistantView): no_attributes = "no_attributes" in request.query if ( - not include_start_time_state + (end_time and not has_recorder_run_after(hass, end_time)) + or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( hass, entity_ids, start_time, no_attributes diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py index 523b1fafb7f..7e28e69e5f9 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import process_timestamp from homeassistant.core import HomeAssistant @@ -21,3 +23,10 @@ def entities_may_have_state_changes_after( return True return False + + +def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool: + """Check if the recorder has any runs after a specific time.""" + return run_time >= process_timestamp( + get_instance(hass).recorder_runs_manager.first.start + ) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 24ec07b6a87..4be63f29c02 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -39,7 +39,7 @@ from homeassistant.helpers.typing import EventType import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES -from .helpers import entities_may_have_state_changes_after +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after _LOGGER = logging.getLogger(__name__) @@ -142,7 +142,8 @@ async def ws_get_history_during_period( no_attributes = msg["no_attributes"] if ( - not include_start_time_state + (end_time and not has_recorder_run_after(hass, end_time)) + or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( hass, entity_ids, start_time, no_attributes From 1522118453972b9396435dd695987f9dace47aab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 11:20:36 -0700 Subject: [PATCH 883/982] Remove aiohttp cancel on disconnect workaround (#104175) --- homeassistant/components/http/__init__.py | 2 +- homeassistant/core.py | 2 - homeassistant/helpers/aiohttp_compat.py | 25 ----------- tests/helpers/test_aiohttp_compat.py | 55 ----------------------- 4 files changed, 1 insertion(+), 83 deletions(-) delete mode 100644 homeassistant/helpers/aiohttp_compat.py delete mode 100644 tests/helpers/test_aiohttp_compat.py diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3ccfa18ef12..449f00fb335 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -520,7 +520,7 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner(self.app) + self.runner = web.AppRunner(self.app, handler_cancellation=True) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/homeassistant/core.py b/homeassistant/core.py index 972e0e618e6..7d9d8d19b49 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -80,7 +80,6 @@ from .exceptions import ( ServiceNotFound, Unauthorized, ) -from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior from .helpers.json import json_dumps from .util import dt as dt_util, location from .util.async_ import ( @@ -113,7 +112,6 @@ STAGE_2_SHUTDOWN_TIMEOUT = 60 STAGE_3_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() -restore_original_aiohttp_cancel_behavior() _T = TypeVar("_T") _R = TypeVar("_R") diff --git a/homeassistant/helpers/aiohttp_compat.py b/homeassistant/helpers/aiohttp_compat.py deleted file mode 100644 index 78aad44fa66..00000000000 --- a/homeassistant/helpers/aiohttp_compat.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Helper to restore old aiohttp behavior.""" -from __future__ import annotations - -from aiohttp import web_protocol, web_server - - -class CancelOnDisconnectRequestHandler(web_protocol.RequestHandler): - """Request handler that cancels tasks on disconnect.""" - - def connection_lost(self, exc: BaseException | None) -> None: - """Handle connection lost.""" - task_handler = self._task_handler - super().connection_lost(exc) - if task_handler is not None: - task_handler.cancel("aiohttp connection lost") - - -def restore_original_aiohttp_cancel_behavior() -> None: - """Patch aiohttp to restore cancel behavior. - - Remove this once aiohttp 3.9 is released as we can use - https://github.com/aio-libs/aiohttp/pull/7128 - """ - web_protocol.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] - web_server.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] diff --git a/tests/helpers/test_aiohttp_compat.py b/tests/helpers/test_aiohttp_compat.py deleted file mode 100644 index 749984dbc2e..00000000000 --- a/tests/helpers/test_aiohttp_compat.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Test the aiohttp compatibility shim.""" - -import asyncio -from contextlib import suppress - -from aiohttp import client, web, web_protocol, web_server -import pytest - -from homeassistant.helpers.aiohttp_compat import CancelOnDisconnectRequestHandler - - -@pytest.mark.allow_hosts(["127.0.0.1"]) -async def test_handler_cancellation(socket_enabled, unused_tcp_port_factory) -> None: - """Test that handler cancels the request on disconnect. - - From aiohttp tests/test_web_server.py - """ - assert web_protocol.RequestHandler is CancelOnDisconnectRequestHandler - assert web_server.RequestHandler is CancelOnDisconnectRequestHandler - - event = asyncio.Event() - port = unused_tcp_port_factory() - - async def on_request(_: web.Request) -> web.Response: - nonlocal event - try: - await asyncio.sleep(10) - except asyncio.CancelledError: - event.set() - raise - else: - raise web.HTTPInternalServerError() - - app = web.Application() - app.router.add_route("GET", "/", on_request) - - runner = web.AppRunner(app) - await runner.setup() - - site = web.TCPSite(runner, host="127.0.0.1", port=port) - - await site.start() - - try: - async with client.ClientSession( - timeout=client.ClientTimeout(total=0.1) - ) as sess: - with pytest.raises(asyncio.TimeoutError): - await sess.get(f"http://127.0.0.1:{port}/") - - with suppress(asyncio.TimeoutError): - await asyncio.wait_for(event.wait(), timeout=1) - assert event.is_set(), "Request handler hasn't been cancelled" - finally: - await asyncio.gather(runner.shutdown(), site.stop()) From af2f8699b7436bf74cfe803b9b460939b82f43ec Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 10:35:36 -0800 Subject: [PATCH 884/982] Add due date and description to CalDAV To-do (#104656) Co-authored-by: Martin Hjelmare Co-authored-by: Robert Resch --- homeassistant/components/caldav/todo.py | 46 ++++++--- tests/components/caldav/test_todo.py | 120 +++++++++++++++++++++--- 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index eddfe410100..1bd24dc542a 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import date, datetime, timedelta from functools import partial import logging -from typing import cast +from typing import Any, cast import caldav from caldav.lib.error import DAVError, NotFoundError @@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from .api import async_get_calendars, get_attr_value from .const import DOMAIN @@ -71,6 +72,12 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: or (summary := get_attr_value(todo, "summary")) is None ): return None + due: date | datetime | None = None + if due_value := get_attr_value(todo, "due"): + if isinstance(due_value, datetime): + due = dt_util.as_local(due_value) + elif isinstance(due_value, date): + due = due_value return TodoItem( uid=uid, summary=summary, @@ -78,9 +85,28 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: get_attr_value(todo, "status") or "", TodoItemStatus.NEEDS_ACTION, ), + due=due, + description=get_attr_value(todo, "description"), ) +def _to_ics_fields(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["summary"] = summary + if status := item.status: + item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + if isinstance(due, datetime): + item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ") + else: + item_data["due"] = due.strftime("%Y%m%d") + if description := item.description: + item_data["description"] = description + return item_data + + class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" @@ -89,6 +115,9 @@ class WebDavTodoListEntity(TodoListEntity): TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: @@ -116,13 +145,7 @@ class WebDavTodoListEntity(TodoListEntity): """Add an item to the To-do list.""" try: await self.hass.async_add_executor_job( - partial( - self._calendar.save_todo, - summary=item.summary, - status=TODO_STATUS_MAP_INV.get( - item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION" - ), - ), + partial(self._calendar.save_todo, **_to_ics_fields(item)), ) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -139,10 +162,7 @@ class WebDavTodoListEntity(TodoListEntity): except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - if item.summary: - vtodo["summary"] = item.summary - if item.status: - vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION") + vtodo.update(**_to_ics_fields(item)) try: await self.hass.async_add_executor_job( partial( diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 55ae0d564d0..6e92f211463 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -17,7 +17,7 @@ from tests.typing import WebSocketGenerator CALENDAR_NAME = "My Tasks" ENTITY_NAME = "My tasks" TEST_ENTITY = "todo.my_tasks" -SUPPORTED_FEATURES = 7 +SUPPORTED_FEATURES = 119 TODO_NO_STATUS = """BEGIN:VCALENDAR VERSION:2.0 @@ -40,6 +40,12 @@ STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" +RESULT_ITEM = { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", +} + TODO_COMPLETED = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//E-Corp.//CalDAV Client//EN @@ -69,6 +75,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_tz(hass: HomeAssistant) -> None: + """Fixture to set timezone with fixed offset year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.fixture(name="todos") def mock_todos() -> list[str]: """Fixture to return VTODO objects for the calendar.""" @@ -178,10 +190,49 @@ async def test_supported_components( assert (state is not None) == has_entity +@pytest.mark.parametrize( + ("item_data", "expcted_save_args", "expected_item"), + [ + ( + {}, + {"status": "NEEDS-ACTION", "summary": "Cheese"}, + RESULT_ITEM, + ), + ( + {"due_date": "2023-11-18"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118"}, + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118T143000Z"}, + {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + ), + ( + {"description": "Make sure to get Swiss"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "description": "Make sure to get Swiss", + }, + {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + ), + ], + ids=[ + "summary", + "due_date", + "due_datetime", + "description", + ], +) async def test_add_item( hass: HomeAssistant, config_entry: MockConfigEntry, + dav_client: Mock, calendar: Mock, + item_data: dict[str, Any], + expcted_save_args: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test adding an item to the list.""" calendar.search.return_value = [] @@ -197,16 +248,13 @@ async def test_add_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Cheese"}, + {"item": "Cheese", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) assert calendar.save_todo.call_args - assert calendar.save_todo.call_args.kwargs == { - "status": "NEEDS-ACTION", - "summary": "Cheese", - } + assert calendar.save_todo.call_args.kwargs == expcted_save_args # Verify state was updated state = hass.states.get(TEST_ENTITY) @@ -235,20 +283,59 @@ async def test_add_item_failure( @pytest.mark.parametrize( - ("update_data", "expected_ics", "expected_state"), + ("update_data", "expected_ics", "expected_state", "expected_item"), [ ( {"rename": "Swiss Cheese"}, ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], "1", + {**RESULT_ITEM, "summary": "Swiss Cheese"}, + ), + ( + {"status": "needs_action"}, + ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], + "1", + RESULT_ITEM, + ), + ( + {"status": "completed"}, + ["SUMMARY:Cheese", "STATUS:COMPLETED"], + "0", + {**RESULT_ITEM, "status": "completed"}, ), - ({"status": "needs_action"}, ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], "1"), - ({"status": "completed"}, ["SUMMARY:Cheese", "STATUS:COMPLETED"], "0"), ( {"rename": "Swiss Cheese", "status": "needs_action"}, ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], "1", + {**RESULT_ITEM, "summary": "Swiss Cheese"}, ), + ( + {"due_date": "2023-11-18"}, + ["SUMMARY:Cheese", "DUE:20231118"], + "1", + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + ["SUMMARY:Cheese", "DUE:20231118T143000Z"], + "1", + {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + ), + ( + {"description": "Make sure to get Swiss"}, + ["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], + "1", + {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + ), + ], + ids=[ + "rename", + "status_needs_action", + "status_completed", + "rename_status", + "due_date", + "due_datetime", + "description", ], ) async def test_update_item( @@ -259,8 +346,9 @@ async def test_update_item( update_data: dict[str, Any], expected_ics: list[str], expected_state: str, + expected_item: dict[str, Any], ) -> None: - """Test creating a an item on the list.""" + """Test updating an item on the list.""" item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") calendar.search = MagicMock(return_value=[item]) @@ -295,6 +383,16 @@ async def test_update_item( assert state assert state.state == expected_state + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + return_response=True, + ) + assert result == {TEST_ENTITY: {"items": [expected_item]}} + async def test_update_item_failure( hass: HomeAssistant, @@ -506,7 +604,7 @@ async def test_subscribe( calendar: Mock, hass_ws_client: WebSocketGenerator, ) -> None: - """Test creating a an item on the list.""" + """Test subscription to item updates.""" item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") calendar.search = MagicMock(return_value=[item]) From 19f543214f6d19e51c2c21d9fffdc004ab7d025a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 20:40:14 +0100 Subject: [PATCH 885/982] Bump version to 2023.12.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c6655ba3900..1fbd97159a7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 71e58bf2177..bd1168d11de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0.dev0" +version = "2023.12.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f1e8c1c7ee983e94f7f1ba4351888787dd7e8367 Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 30 Nov 2023 13:14:46 +0200 Subject: [PATCH 886/982] Fix wirelesstag unique_id to use uuid instead of tag_id (#104394) Co-authored-by: Robert Resch --- .../components/wirelesstag/__init__.py | 16 ++++++++++ .../components/wirelesstag/binary_sensor.py | 12 ++++--- .../components/wirelesstag/sensor.py | 18 +++++++---- .../components/wirelesstag/switch.py | 32 +++++++++++-------- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 06fbfa3621e..f95337dbaf4 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -5,6 +5,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException +from wirelesstagpy.sensortag import SensorTag from homeassistant.components import persistent_notification from homeassistant.const import ( @@ -17,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity @@ -126,6 +128,20 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) +def async_migrate_unique_id(hass: HomeAssistant, tag: SensorTag, domain: str, key: str): + """Migrate old unique id to new one with use of tag's uuid.""" + registry = er.async_get(hass) + new_unique_id = f"{tag.uuid}_{key}" + + if registry.async_get_entity_id(domain, DOMAIN, new_unique_id): + return + + old_unique_id = f"{tag.tag_id}_{key}" + if entity_id := registry.async_get_entity_id(domain, DOMAIN, old_unique_id): + _LOGGER.debug("Updating unique id for %s %s", key, entity_id) + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Wireless Sensor Tag component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 711c2987735..64a1097bcab 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON +from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -15,6 +15,7 @@ from . import ( DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, WirelessTagBaseSensor, + async_migrate_unique_id, ) # On means in range, Off means out of range @@ -72,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the platform for a WirelessTags.""" @@ -87,9 +88,10 @@ def setup_platform( allowed_sensor_types = tag.supported_binary_events_types for sensor_type in config[CONF_MONITORED_CONDITIONS]: if sensor_type in allowed_sensor_types: + async_migrate_unique_id(hass, tag, Platform.BINARY_SENSOR, sensor_type) sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): @@ -100,7 +102,7 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): super().__init__(api, tag) self._sensor_type = sensor_type self._name = f"{self._tag.name} {self.event.human_readable_name}" - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index fd9a7898f92..8ae20031723 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -12,14 +12,19 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + SIGNAL_TAG_UPDATE, + WirelessTagBaseSensor, + async_migrate_unique_id, +) _LOGGER = logging.getLogger(__name__) @@ -68,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" @@ -83,9 +88,10 @@ def setup_platform( if key not in tag.allowed_sensor_types: continue description = SENSOR_TYPES[key] + async_migrate_unique_id(hass, tag, Platform.SENSOR, description.key) sensors.append(WirelessTagSensor(platform, tag, description)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): @@ -100,7 +106,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): self._sensor_type = description.key self.entity_description = description self._name = self._tag.name - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" # I want to see entity_id as: # sensor.wirelesstag_bedroom_temperature diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index df0f72aca18..7f4008623b1 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -10,13 +10,17 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WirelessTagBaseSensor, + async_migrate_unique_id, +) SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -52,10 +56,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switches for a Wireless Sensor Tags.""" @@ -63,15 +67,17 @@ def setup_platform( tags = platform.load_tags() monitored_conditions = config[CONF_MONITORED_CONDITIONS] - entities = [ - WirelessTagSwitch(platform, tag, description) - for tag in tags.values() - for description in SWITCH_TYPES - if description.key in monitored_conditions - and description.key in tag.allowed_monitoring_types - ] + entities = [] + for tag in tags.values(): + for description in SWITCH_TYPES: + if ( + description.key in monitored_conditions + and description.key in tag.allowed_monitoring_types + ): + async_migrate_unique_id(hass, tag, Platform.SWITCH, description.key) + entities.append(WirelessTagSwitch(platform, tag, description)) - add_entities(entities, True) + async_add_entities(entities, True) class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): @@ -82,7 +88,7 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): super().__init__(api, tag) self.entity_description = description self._name = f"{self._tag.name} {description.name}" - self._attr_unique_id = f"{self.tag_id}_{description.key}" + self._attr_unique_id = f"{self._uuid}_{description.key}" def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" From 4acea82ca13d4b60148d7ce03a24e29a9e1f8bb4 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 29 Nov 2023 21:54:05 +0100 Subject: [PATCH 887/982] Fix Philips TV none recordings_list (#104665) Correct for missing recordings list in api client. --------- Co-authored-by: Joakim Plate --- .../components/philips_js/binary_sensor.py | 2 ++ .../philips_js/test_binary_sensor.py | 31 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 78aa9f17b05..1e6c1241aea 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -66,6 +66,8 @@ async def async_setup_entry( def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: """Return True if at least one specified value is available within entry of list.""" + if api.recordings_list is None: + return False for rec in api.recordings_list["recordings"]: if rec.get(entry) == value: return True diff --git a/tests/components/philips_js/test_binary_sensor.py b/tests/components/philips_js/test_binary_sensor.py index d11f3fe22f1..01233706d07 100644 --- a/tests/components/philips_js/test_binary_sensor.py +++ b/tests/components/philips_js/test_binary_sensor.py @@ -1,7 +1,7 @@ """The tests for philips_js binary_sensor.""" import pytest -from homeassistant.const import STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from . import MOCK_NAME, MOCK_RECORDINGS_LIST @@ -32,7 +32,16 @@ async def mock_tv_api_valid(mock_tv): return mock_tv -async def test_recordings_list_invalid( +@pytest.fixture +async def mock_tv_recordings_list_unavailable(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = None + return mock_tv + + +async def test_recordings_list_api_invalid( mock_tv_api_invalid, mock_config_entry, hass: HomeAssistant ) -> None: """Test if sensors are not created if mock_tv is invalid.""" @@ -54,7 +63,21 @@ async def test_recordings_list_valid( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) state = hass.states.get(ID_RECORDING_AVAILABLE) - assert state.state is STATE_ON + assert state.state == STATE_ON state = hass.states.get(ID_RECORDING_ONGOING) - assert state.state is STATE_ON + assert state.state == STATE_ON + + +async def test_recordings_list_unavailable( + mock_tv_recordings_list_unavailable, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state == STATE_OFF + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state == STATE_OFF From 6f45fafc11507302a65e2212159bb9a60c70a499 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 29 Nov 2023 17:13:05 -0500 Subject: [PATCH 888/982] Bump pynws to 1.6.0 (#104679) --- homeassistant/components/nws/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 05194d85a26..4006a145db4 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.5.1"] + "requirements": ["pynws==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ec0e5f232e2..d19df8c1c9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1922,7 +1922,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75947854c7f..5bf8f26c874 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1451,7 +1451,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 From 78f1c0cb803ee444a392f7ef17522615e1c7aa1c Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:12:39 +0100 Subject: [PATCH 889/982] Axis: add host and user name field description (#104693) --- homeassistant/components/axis/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 47a25b542a7..8c302dba201 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -3,12 +3,16 @@ "flow_title": "{name} ({host})", "step": { "user": { - "title": "Set up Axis device", + "description": "Set up an Axis device", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Axis device.", + "username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant." } } }, From 5f549649de60a7f69053587c81f8885c8c105890 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:38:33 +0100 Subject: [PATCH 890/982] Update initial translation for ViCare water heater entity (#104696) --- homeassistant/components/vicare/strings.json | 4 ++-- homeassistant/components/vicare/water_heater.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index e9ee272edd8..47ee60b2ea8 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -283,8 +283,8 @@ } }, "water_heater": { - "water": { - "name": "Water" + "domestic_hot_water": { + "name": "Domestic hot water" } } }, diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 036ced5ee55..66a90ca065b 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -64,13 +64,13 @@ def _build_entities( api: PyViCareDevice, device_config: PyViCareDeviceConfig, ) -> list[ViCareWater]: - """Create ViCare water entities for a device.""" + """Create ViCare domestic hot water entities for a device.""" return [ ViCareWater( api, circuit, device_config, - "water", + "domestic_hot_water", ) for circuit in get_circuits(api) ] @@ -81,7 +81,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the ViCare climate platform.""" + """Set up the ViCare water heater platform.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] From 34c65749e2d3cc4e52d2979d2749fbca94e56845 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 23:16:58 +0100 Subject: [PATCH 891/982] Revert "Add proj dependency to our wheels builder (#104699)" (#104704) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9d16954cd09..3b23f1b5b05 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 90bcad31b5c2694b6765976ae19c96b0c58eee99 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Nov 2023 18:31:27 -0600 Subject: [PATCH 892/982] Skip TTS when text is empty (#104741) Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/pipeline.py | 59 ++++++++------- .../components/esphome/voice_assistant.py | 22 +++--- homeassistant/components/voip/voip.py | 15 ++-- .../snapshots/test_websocket.ambr | 27 +++++++ .../assist_pipeline/test_websocket.py | 51 +++++++++++++ .../esphome/test_voice_assistant.py | 22 ++++++ tests/components/voip/test_voip.py | 72 +++++++++++++++++++ 7 files changed, 225 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1eb32a9dc3f..4f2a9a8d99b 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1024,39 +1024,38 @@ class PipelineRun: ) ) - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error + if tts_input := tts_input.strip(): + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text-to-speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text-to-speech", + ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } + else: + tts_output = {} self.process_event( - PipelineEvent( - PipelineEventType.TTS_END, - { - "tts_output": { - "media_id": tts_media_id, - **asdict(tts_media), - } - }, - ) + PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) return tts_media.url diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 68ed98aa789..de6b521d980 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -186,16 +186,22 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): data_to_send = {"text": event.data["tts_input"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert event.data is not None - path = event.data["tts_output"]["url"] - url = async_process_play_media_url(self.hass, path) - data_to_send = {"url": url} + tts_output = event.data["tts_output"] + if tts_output: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} - if self.device_info.voice_assistant_version >= 2: - media_id = event.data["tts_output"]["media_id"] - self._tts_task = self.hass.async_create_background_task( - self._send_tts(media_id), "esphome_voice_assistant_tts" - ) + if self.device_info.voice_assistant_version >= 2: + media_id = tts_output["media_id"] + self._tts_task = self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() else: + # Empty TTS response + data_to_send = {} self._tts_done.set() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: assert event.data is not None diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 74bc94e7dc5..11f70c631f1 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -389,11 +389,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - media_id = event.data["tts_output"]["media_id"] - self.hass.async_create_background_task( - self._send_tts(media_id), - "voip_pipeline_tts", - ) + tts_output = event.data["tts_output"] + if tts_output: + media_id = tts_output["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + else: + # Empty TTS response + self._tts_done.set() elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS self._pipeline_error = True diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 1f625528806..072b1ff730a 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -650,6 +650,33 @@ 'message': 'Timeout running pipeline', }) # --- +# name: test_pipeline_empty_tts_output + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': None, + 'timeout': 300, + }), + }) +# --- +# name: test_pipeline_empty_tts_output.1 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': '', + 'voice': 'james_earl_jones', + }) +# --- +# name: test_pipeline_empty_tts_output.2 + dict({ + 'tts_output': dict({ + }), + }) +# --- +# name: test_pipeline_empty_tts_output.3 + None +# --- # name: test_stt_provider_missing dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 931b31dd77b..0e2a3ad538c 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2452,3 +2452,54 @@ async def test_device_capture_queue_full( assert msg["event"] == snapshot assert msg["event"]["type"] == "end" assert msg["event"]["overflow"] + + +async def test_pipeline_empty_tts_output( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with a empty text-to-speech text.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": { + "text": "", + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + assert not msg["event"]["data"]["tts_output"] + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index ca74c99f0cd..38a33bfdec2 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -337,6 +337,28 @@ async def test_send_tts_called( mock_send_tts.assert_called_with(_TEST_MEDIA_ID) +async def test_send_tts_not_called_when_empty( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v1/v2 device doesn't call _send_tts when the output is empty.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v1._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + async def test_send_tts( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 692896c6dfa..dbb848f3b9d 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -528,3 +528,75 @@ async def test_tts_wrong_wav_format( # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): await done.wait() + + +async def test_empty_tts_output( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will not stream when output is empty.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Empty TTS output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {}}, + ) + ) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._send_tts", + ) as mock_send_tts: + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to finish + async with asyncio.timeout(1): + await rtp_protocol._tts_done.wait() + + mock_send_tts.assert_not_called() From f366b37c529460cf473e056c080d12afd20999d3 Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:31:39 +0100 Subject: [PATCH 893/982] Bump zamg to 0.3.3 (#104756) --- homeassistant/components/zamg/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index df17672231e..f83e38002b8 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.0"] + "requirements": ["zamg==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index d19df8c1c9a..de84d8fb420 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2807,7 +2807,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bf8f26c874..f2241b1c5d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2105,7 +2105,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zeroconf zeroconf==0.127.0 From b4907800a9c673672caa0d4e0c8a8957e7f6a1b6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Nov 2023 00:32:12 +0100 Subject: [PATCH 894/982] Debug level logging for DSMR migration code (#104757) --- homeassistant/components/dsmr/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 487f996ac1f..f56e2c3ed33 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -456,12 +456,12 @@ def rename_old_gas_to_mbus( device_id=mbus_device_id, ) except ValueError: - LOGGER.warning( + LOGGER.debug( "Skip migration of %s because it already exists", entity.entity_id, ) else: - LOGGER.info( + LOGGER.debug( "Migrated entity %s from unique id %s to %s", entity.entity_id, entity.unique_id, From 4b22551af12e66dd7fff9b69a6845737a22247b7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 21:11:38 -0800 Subject: [PATCH 895/982] Fix bug in rainbird device ids that are int serial numbers (#104768) --- homeassistant/components/rainbird/__init__.py | 2 +- tests/components/rainbird/test_init.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index c149c993acb..e5731dc08fe 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -183,7 +183,7 @@ def _async_fix_device_id( device_entry_map = {} migrations = {} for device_entry in device_entries: - unique_id = next(iter(device_entry.identifiers))[1] + unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 7048e1d63f4..00cbefc6556 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -239,6 +239,14 @@ async def test_fix_unique_id_duplicate( f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", f"{MAC_ADDRESS_UNIQUE_ID}-1", ), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), ("0", 0, "0", "0", MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID), ( "0", @@ -268,6 +276,7 @@ async def test_fix_unique_id_duplicate( ids=( "serial-number", "serial-number-with-suffix", + "serial-number-int", "zero-serial", "zero-serial-suffix", "new-format", From 816e524457158a38a17999f2e03805f465d725d9 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:47:37 +0100 Subject: [PATCH 896/982] Broadlink, BSB-Lan: add host field description (#104770) --- homeassistant/components/broadlink/strings.json | 5 ++++- homeassistant/components/bsblan/strings.json | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 87567bcb7b1..335984d1ebe 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -3,10 +3,13 @@ "flow_title": "{name} ({model} at {host})", "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "timeout": "Timeout" + }, + "data_description": { + "host": "The hostname or IP address of your Broadlink device." } }, "auth": { diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 0693f3fb8ea..689d1f893d3 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -11,6 +11,9 @@ "passkey": "Passkey string", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your BSB-Lan device." } } }, From fe544f670fe7e3f435dd19049f85d20df601eb25 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:47:17 +0100 Subject: [PATCH 897/982] Comelit, Coolmaster: add host field description (#104771) --- homeassistant/components/comelit/strings.json | 3 +++ homeassistant/components/coolmaster/strings.json | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 730674e913a..73c2c7d00c6 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "host": "The hostname or IP address of your Comelit device." } } }, diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 7baa6444c1d..17deab306df 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up your CoolMasterNet connection details.", + "description": "Set up your CoolMasterNet connection details.", "data": { "host": "[%key:common::config_flow::data::host%]", "off": "Can be turned off", @@ -12,6 +12,9 @@ "dry": "Support dry mode", "fan_only": "Support fan only mode", "swing_support": "Control swing mode" + }, + "data_description": { + "host": "The hostname or IP address of your CoolMasterNet device." } } }, From 4eec48de51bd5720aa649bc628b37d095eae5950 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:24:21 +0100 Subject: [PATCH 898/982] Deconz to DoorBird: add host field description (#104772) * Deconz to DoorBird: add host field description * Update homeassistant/components/deconz/strings.json Co-authored-by: Robert Svensson --------- Co-authored-by: Robert Svensson --- homeassistant/components/deconz/strings.json | 5 ++++- homeassistant/components/deluge/strings.json | 3 +++ homeassistant/components/directv/strings.json | 3 +++ homeassistant/components/dlink/strings.json | 1 + homeassistant/components/doorbird/strings.json | 5 ++++- 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index e32ab875c28..c06a07e6ce5 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -11,11 +11,14 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your deCONZ host." } }, "link": { "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button" }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Home Assistant add-on", diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index e0266d004e2..52706f39894 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", "web_port": "Web port (for visiting service)" + }, + "data_description": { + "host": "The hostname or IP address of your Deluge device." } } }, diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index 8ed52cd3632..2c30e3db85c 100644 --- a/homeassistant/components/directv/strings.json +++ b/homeassistant/components/directv/strings.json @@ -8,6 +8,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your DirectTV device." } } }, diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index 8c60d59fa6b..9f21a9571e9 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -9,6 +9,7 @@ "use_legacy_protocol": "Use legacy protocol" }, "data_description": { + "host": "The hostname or IP address of your D-Link device", "password": "Default: PIN code on the back." } }, diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index ceaf1a891ee..c851de379d4 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -17,8 +17,11 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "host": "[%key:common::config_flow::data::host%]", - "name": "Device Name", + "name": "Device name", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your DoorBird device." } } }, From c3566db339f3b14e6951fd41b8572f8057f02b18 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:43:42 +0100 Subject: [PATCH 899/982] Dremel to Duotecno: add host field description (#104776) Co-authored-by: Franck Nijhof --- homeassistant/components/dremel_3d_printer/strings.json | 3 +++ homeassistant/components/dunehd/strings.json | 3 +++ homeassistant/components/duotecno/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 0016b8f2bca..9f6870b57f6 100644 --- a/homeassistant/components/dremel_3d_printer/strings.json +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dremel 3D printer." } } }, diff --git a/homeassistant/components/dunehd/strings.json b/homeassistant/components/dunehd/strings.json index f7e12b39f16..7d60a720a98 100644 --- a/homeassistant/components/dunehd/strings.json +++ b/homeassistant/components/dunehd/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your player is turned on.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dune HD device." } } }, diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 93a545d31dc..a5585c3dd2c 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Duotecno device." } } }, From b6b2cf194d31fae07eea32d604736473b07d7aeb Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:44:18 +0100 Subject: [PATCH 900/982] Ecoforest to Emonitor: add host field description (#104778) --- homeassistant/components/ecoforest/strings.json | 3 +++ homeassistant/components/elgato/strings.json | 3 +++ homeassistant/components/emonitor/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index d1767be5cda..1094e10ada3 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ecoforest device." } } }, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index e6b16215793..6e1031c8ddf 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Elgato device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json index 675db107935..08ffe030890 100644 --- a/homeassistant/components/emonitor/strings.json +++ b/homeassistant/components/emonitor/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your SiteSage Emonitor device." } }, "confirm": { From e1504759fed7bfbf497cf1e77291adf0ab796e20 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:25:04 +0100 Subject: [PATCH 901/982] Enphase to Evil: add host field description (#104779) Co-authored-by: Franck Nijhof --- homeassistant/components/enphase_envoy/strings.json | 3 +++ homeassistant/components/epson/strings.json | 3 +++ homeassistant/components/evil_genius_labs/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 94cf9233745..fe32002e6b2 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Enphase Envoy gateway." } } }, diff --git a/homeassistant/components/epson/strings.json b/homeassistant/components/epson/strings.json index 4e3780322e9..94544c32d1d 100644 --- a/homeassistant/components/epson/strings.json +++ b/homeassistant/components/epson/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Epson projector." } } }, diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json index 790e9a69c7f..123d164444d 100644 --- a/homeassistant/components/evil_genius_labs/strings.json +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Evil Genius Labs device." } } }, From 40c7432e8a879bb1846c91d5835031f1ae37bfa3 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 11:59:20 +0100 Subject: [PATCH 902/982] FiveM to Foscam: add host field description (#104782) --- homeassistant/components/fivem/strings.json | 3 +++ homeassistant/components/flo/strings.json | 3 +++ homeassistant/components/foscam/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index 2ffb401f8c0..abdef61fb28 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -6,6 +6,9 @@ "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your FiveM server." } } }, diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 627f562be7e..3444911fbd4 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Flo device." } } }, diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 35964ee4546..de22006b274 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "rtsp_port": "RTSP port", "stream": "Stream" + }, + "data_description": { + "host": "The hostname or IP address of your Foscam camera." } } }, From ddba7d8ed8ed3b6d3f482ee0047bb8d8213fedb5 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:05:32 +0100 Subject: [PATCH 903/982] Freebox to FRITZ!Box add host field description (#104784) Co-authored-by: Simone Chemelli --- homeassistant/components/freebox/strings.json | 3 +++ homeassistant/components/fritz/strings.json | 3 +++ homeassistant/components/fritzbox/strings.json | 3 +++ homeassistant/components/fritzbox_callmonitor/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index 5c4143b4562..eaa56a38da1 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Freebox router." } }, "link": { diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 7cbb10a236b..5eed2f59fc4 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -26,6 +26,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } } }, diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index d5607aa3090..f4d2fe3670e 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "confirm": { diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 89f049bfbe9..ac36942eec2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -8,6 +8,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "phonebook": { From 04b72953e6dfd72f02e6096c4bae4dfabae2f109 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 30 Nov 2023 17:11:28 +0100 Subject: [PATCH 904/982] Fix Fastdotcom no entity (#104785) Co-authored-by: G Johansson --- homeassistant/components/fastdotcom/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 33ad4853404..939ab4a40e5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(hass.data[DOMAIN])]) + async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) # pylint: disable-next=hass-invalid-inheritance # needs fixing @@ -38,9 +38,10 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_icon = "mdi:speedometer" _attr_should_poll = False - def __init__(self, speedtest_data: dict[str, Any]) -> None: + def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" self._speedtest_data = speedtest_data + self._attr_unique_id = entry_id async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" From d7de9c13fdd04c7d55c9113d86c6f91e382e53c7 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:04:15 +0100 Subject: [PATCH 905/982] Goalzero to HEOS: add host field description (#104786) --- homeassistant/components/goalzero/strings.json | 3 +++ homeassistant/components/harmony/strings.json | 3 +++ homeassistant/components/heos/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index d94f5219607..c6d85bd4c10 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Goal Zero Yeti." } }, "confirm_discovery": { diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index c9c7a559758..f6862ca3c83 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "Hub Name" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Harmony Hub." } }, "link": { diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 7bd362cf3d7..df18fc7834a 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -6,6 +6,9 @@ "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your HEOS device." } } }, From 62537aa63a752161de014d23a1b352d71f7de10d Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:55:53 +0100 Subject: [PATCH 906/982] Frontier to Glances: add host field description (#104787) Co-authored-by: Franck Nijhof --- homeassistant/components/frontier_silicon/strings.json | 5 ++++- homeassistant/components/fully_kiosk/strings.json | 3 +++ homeassistant/components/glances/strings.json | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a10c3f535a1..03d9f28c016 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -5,10 +5,13 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Frontier Silicon device." } }, "device_config": { - "title": "Device Configuration", + "title": "Device configuration", "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", "data": { "pin": "[%key:common::config_flow::data::pin%]" diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index bf46feeec3f..c1a1ef1fcf0 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -13,6 +13,9 @@ "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your Fully Kiosk Browser application." } } }, diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index fdd0c44b31b..1bab098d65f 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -10,6 +10,9 @@ "version": "Glances API Version (2 or 3)", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system running your Glances system monitor." } }, "reauth_confirm": { From fd442fadf8edfbb82ba3b9bb34f4ed4bc744c2b5 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:38:40 +0100 Subject: [PATCH 907/982] HLK to Hyperion: add host field description (#104789) Co-authored-by: Franck Nijhof --- homeassistant/components/hlk_sw16/strings.json | 3 +++ homeassistant/components/hue/strings.json | 6 ++++++ homeassistant/components/hyperion/strings.json | 3 +++ 3 files changed, 12 insertions(+) diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json index d6e3212b4ea..ba74547e355 100644 --- a/homeassistant/components/hlk_sw16/strings.json +++ b/homeassistant/components/hlk_sw16/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hi-Link HLK-SW-16 device." } } }, diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 4022c61bc36..122cb489d26 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -5,12 +5,18 @@ "title": "Pick Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "manual": { "title": "Manual configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "link": { diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index a2f8838e2ea..8d7e3751c4c 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hyperion server." } }, "auth": { From cf63cd33c5eea397b70d2844e000c8461afe6422 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:03:27 +0100 Subject: [PATCH 908/982] iAlarm to Keenetic: add host field description (#104791) Co-authored-by: Andrey Kupreychik --- homeassistant/components/ialarm/strings.json | 3 +++ homeassistant/components/iotawatt/strings.json | 3 +++ homeassistant/components/keenetic_ndms2/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ialarm/strings.json b/homeassistant/components/ialarm/strings.json index 1ac7a25e6f8..cb2c75d74a9 100644 --- a/homeassistant/components/ialarm/strings.json +++ b/homeassistant/components/ialarm/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of Antifurto365 iAlarm system." } } }, diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json index f21dfe0cd09..266b32c5c31 100644 --- a/homeassistant/components/iotawatt/strings.json +++ b/homeassistant/components/iotawatt/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your IoTaWatt device." } }, "auth": { diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 13e3fabfbff..765a3fc4d47 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -9,6 +9,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Keenetic router." } } }, From 75d2ea9c57098e15e23c4ee8718dc0937e0a9a4c Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:57:44 +0100 Subject: [PATCH 909/982] KMtronic to LG Soundbar: add host field description (#104792) --- homeassistant/components/kmtronic/strings.json | 3 +++ homeassistant/components/kodi/strings.json | 3 +++ homeassistant/components/lg_soundbar/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 2a3a3a40687..6cecea12f22 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your KMtronic device." } } }, diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 51431b317d6..7c7d53b33ac 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system hosting your Kodi server." } }, "discovery_confirm": { diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json index 8c6a9909ff5..ee16a39350c 100644 --- a/homeassistant/components/lg_soundbar/strings.json +++ b/homeassistant/components/lg_soundbar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LG Soundbar." } } }, From ea8a47d0e9d542de4a29223e2306d856d547b4d3 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 16:59:26 +0100 Subject: [PATCH 910/982] Fix device sync to Google Assistant if Matter integration is active (#104796) * Only get Matter device info if device is an actual Matter device * Return None if matter device does not exist * lint * fix test * adjust google assistant test --- homeassistant/components/google_assistant/helpers.py | 8 ++++++-- homeassistant/components/matter/helpers.py | 2 +- tests/components/google_assistant/test_helpers.py | 1 + tests/components/matter/test_helpers.py | 11 ++++------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index af892f15af4..c89925664e0 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -686,8 +686,12 @@ class GoogleEntity: return device # Add Matter info - if "matter" in self.hass.config.components and ( - matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + if ( + "matter" in self.hass.config.components + and any(x for x in device_entry.identifiers if x[0] == "matter") + and ( + matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + ) ): device["matterUniqueId"] = matter_info["unique_id"] device["matterOriginalVendorId"] = matter_info["vendor_id"] diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index dcd6a30ee1f..446d5dc3591 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -94,7 +94,7 @@ def get_node_from_device_entry( ) if device_id_full is None: - raise ValueError(f"Device {device.id} is not a Matter device") + return None device_id = device_id_full.lstrip(device_id_type_prefix) matter_client = matter.matter_client diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 771df137278..aaa3949caaf 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -89,6 +89,7 @@ async def test_google_entity_sync_serialize_with_matter( manufacturer="Someone", model="Some model", sw_version="Some Version", + identifiers={("matter", "12345678")}, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity = entity_registry.async_get_or_create( diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index c7a0ed0d8a3..61988a37122 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -60,16 +60,13 @@ async def test_get_node_from_device_entry( assert node_from_device_entry is node - with pytest.raises(ValueError) as value_error: - await get_node_from_device_entry(hass, other_device_entry) - - assert f"Device {other_device_entry.id} is not a Matter device" in str( - value_error.value - ) + # test non-Matter device returns None + assert get_node_from_device_entry(hass, other_device_entry) is None matter_client.server_info = None + # test non-initialized server raises RuntimeError with pytest.raises(RuntimeError) as runtime_error: - node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + node_from_device_entry = get_node_from_device_entry(hass, device_entry) assert "Matter server information is not available" in str(runtime_error.value) From 0eefc98b3395f55ac610cb40f9654d60e33992da Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:50:58 +0100 Subject: [PATCH 911/982] Fix runtime error in CalDAV (#104800) --- homeassistant/components/caldav/api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index f9236049048..fa89d6acc38 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -11,7 +11,11 @@ async def async_get_calendars( hass: HomeAssistant, client: caldav.DAVClient, component: str ) -> list[caldav.Calendar]: """Get all calendars that support the specified component.""" - calendars = await hass.async_add_executor_job(client.principal().calendars) + + def _get_calendars() -> list[caldav.Calendar]: + return client.principal().calendars() + + calendars = await hass.async_add_executor_job(_get_calendars) components_results = await asyncio.gather( *[ hass.async_add_executor_job(calendar.get_supported_components) From 847fd4c653047dd4b0472cc29a43d73d87595513 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 30 Nov 2023 08:22:17 -0500 Subject: [PATCH 912/982] Use .get for Fully Kiosk SSL settings in coordinator (#104801) --- homeassistant/components/fully_kiosk/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 17facb79dbb..203251351ae 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -19,13 +19,14 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" + self.use_ssl = entry.data.get(CONF_SSL, False) self.fully = FullyKiosk( async_get_clientsession(hass), entry.data[CONF_HOST], DEFAULT_PORT, entry.data[CONF_PASSWORD], - use_ssl=entry.data[CONF_SSL], - verify_ssl=entry.data[CONF_VERIFY_SSL], + use_ssl=self.use_ssl, + verify_ssl=entry.data.get(CONF_VERIFY_SSL, False), ) super().__init__( hass, @@ -33,7 +34,6 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): name=entry.data[CONF_HOST], update_interval=UPDATE_INTERVAL, ) - self.use_ssl = entry.data[CONF_SSL] async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" From 9d53d6811a28b8eddc7049e03f8b0ca3c1accfb8 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 17:09:53 +0100 Subject: [PATCH 913/982] Bump python-matter-server to version 5.0.0 (#104805) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 174ebb1cab9..f350cda9227 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==4.0.2"] + "requirements": ["python-matter-server==5.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index de84d8fb420..1c5ac428193 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2169,7 +2169,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2241b1c5d4..f8a4683b88d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1623,7 +1623,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 83d881459a92cf68ae577739d81cf5c8af109716 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 17:45:27 +0100 Subject: [PATCH 914/982] Add NodeStrClass.__voluptuous_compile__ (#104808) --- homeassistant/util/yaml/objects.py | 7 +++++++ tests/util/yaml/test_init.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index b2320a74d2c..6aedc85cf60 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,7 +2,10 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any +import voluptuous as vol +from voluptuous.schema_builder import _compile_scalar import yaml @@ -13,6 +16,10 @@ class NodeListClass(list): class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" + def __voluptuous_compile__(self, schema: vol.Schema) -> Any: + """Needed because vol.Schema.compile does not handle str subclasses.""" + return _compile_scalar(self) + class NodeDictClass(dict): """Wrapper class to be able to add attributes on a dict.""" diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 990956ec908..a4b243a4b4a 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -8,6 +8,7 @@ import unittest from unittest.mock import patch import pytest +import voluptuous as vol import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file @@ -615,3 +616,20 @@ def test_string_annotated(try_both_loaders) -> None: getattr(value, "__config_file__", None) == expected_annotations[key][1][0] ) assert getattr(value, "__line__", None) == expected_annotations[key][1][1] + + +def test_string_used_as_vol_schema(try_both_loaders) -> None: + """Test the subclassed strings can be used in voluptuous schemas.""" + conf = "wanted_data:\n key_1: value_1\n key_2: value_2\n" + with io.StringIO(conf) as file: + doc = yaml_loader.parse_yaml(file) + + # Test using the subclassed strings in a schema + schema = vol.Schema( + {vol.Required(key): value for key, value in doc["wanted_data"].items()}, + ) + # Test using the subclassed strings when validating a schema + schema(doc["wanted_data"]) + schema({"key_1": "value_1", "key_2": "value_2"}) + with pytest.raises(vol.Invalid): + schema({"key_1": "value_2", "key_2": "value_1"}) From 7a36bdb052faf3873370698b163ba31e735a174a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Nov 2023 17:26:07 +0100 Subject: [PATCH 915/982] Make Shelly Wall Display thermostat implementation compatible with firmware 1.2.5 (#104812) --- homeassistant/components/shelly/climate.py | 11 ++++------- homeassistant/components/shelly/switch.py | 5 +++-- homeassistant/components/shelly/utils.py | 7 ------- tests/components/shelly/conftest.py | 3 +-- tests/components/shelly/test_switch.py | 11 +++++++---- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index d855e8b238b..6a592c904f6 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -42,12 +42,7 @@ from .const import ( ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyRpcEntity -from .utils import ( - async_remove_shelly_entity, - get_device_entry_gen, - get_rpc_key_ids, - is_relay_used_as_actuator, -) +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -131,7 +126,9 @@ def async_setup_rpc_entry( for id_ in climate_key_ids: climate_ids.append(id_) - if is_relay_used_as_actuator(id_, coordinator.mac, coordinator.device.config): + if coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is used as the thermostat actuator, + # we need to remove a switch entity unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 35429c858f5..5a398182e4d 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -118,8 +118,9 @@ def async_setup_rpc_entry( continue if coordinator.model == MODEL_WALL_DISPLAY: - if coordinator.device.shelly["relay_operational"]: - # Wall Display in relay mode, we need to remove a climate entity + if not coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is not used as the thermostat actuator, + # we need to remove a climate entity unique_id = f"{coordinator.mac}-thermostat:{id_}" async_remove_shelly_entity(hass, "climate", unique_id) else: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 0209dc63aa8..6b5c59f28db 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -430,10 +430,3 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL - - -def is_relay_used_as_actuator(relay_id: int, mac: str, config: dict[str, Any]) -> bool: - """Return True if an internal relay is used as the thermostat actuator.""" - return f"{mac}/c/switch:{relay_id}".lower() in config[f"thermostat:{relay_id}"].get( - "actuator", "" - ) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 1662405dc80..6eb74e26dcb 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -153,7 +153,6 @@ MOCK_CONFIG = { "id": 0, "enable": True, "type": "heating", - "actuator": f"shelly://shellywalldisplay-{MOCK_MAC.lower()}/c/switch:0", }, "sys": { "ui_data": {}, @@ -181,7 +180,7 @@ MOCK_SHELLY_RPC = { "auth_en": False, "auth_domain": None, "profile": "cover", - "relay_operational": False, + "relay_in_thermostat": True, } MOCK_STATUS_COAP = { diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 69e1423f75a..e19416706e1 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -283,7 +283,8 @@ async def test_block_device_gas_valve( async def test_wall_display_thermostat_mode( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, + mock_rpc_device, ) -> None: """Test Wall Display in thermostat mode.""" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -294,7 +295,10 @@ async def test_wall_display_thermostat_mode( async def test_wall_display_relay_mode( - hass: HomeAssistant, entity_registry, mock_rpc_device, monkeypatch + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, ) -> None: """Test Wall Display in thermostat mode.""" entity_id = register_entity( @@ -305,8 +309,7 @@ async def test_wall_display_relay_mode( ) new_shelly = deepcopy(mock_rpc_device.shelly) - new_shelly["relay_operational"] = True - + new_shelly["relay_in_thermostat"] = False monkeypatch.setattr(mock_rpc_device, "shelly", new_shelly) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) From 7e012183da107c251527e89762230000ef227413 Mon Sep 17 00:00:00 2001 From: Mappenhei <124632181+Mappenhei@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:45:52 +0100 Subject: [PATCH 916/982] Add Humidity device class to LaCross humidity sensor (#104814) --- homeassistant/components/lacrosse/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 7355a60f5f0..40d38da55eb 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -209,7 +209,7 @@ class LaCrosseHumidity(LaCrosseSensor): _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT - _attr_icon = "mdi:water-percent" + _attr_device_class = SensorDeviceClass.HUMIDITY @property def native_value(self) -> int | None: From 43e0ddc74ee964507437d7d84805b0152dc8ab4e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Nov 2023 18:46:18 +0100 Subject: [PATCH 917/982] Address late review for the host field description in Shelly integration (#104815) Co-authored-by: Franck Nijhof --- homeassistant/components/shelly/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 49c66a56459..9230ae605e0 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -8,7 +8,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of the Shelly device to control." + "host": "The hostname or IP address of the Shelly device to connect to." } }, "credentials": { From 7739f9923379dccfaf4338421a1a27a94352cd58 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Nov 2023 17:49:31 +0100 Subject: [PATCH 918/982] Update frontend to 20231130.0 (#104816) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7a587d56d74..b6668383b54 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231129.1"] + "requirements": ["home-assistant-frontend==20231130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 75209de5996..7dad258068d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1c5ac428193..54a598ad9b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8a4683b88d..82a65044067 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 45f79ee1ba25fb4c0434bb8f0b61854086e8419b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 18:14:48 +0100 Subject: [PATCH 919/982] Restore renamed yaml loader classes and warn when used (#104818) --- homeassistant/util/yaml/loader.py | 25 ++++++++++++++ tests/util/yaml/test_init.py | 55 ++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index fbffae448b2..e8f4a734bdb 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -23,6 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -136,6 +137,18 @@ 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.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + FastSafeLoader.__init__(*args, **kwargs) + + class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -145,6 +158,18 @@ 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.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + PythonSafeLoader.__init__(*args, **kwargs) + + LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index a4b243a4b4a..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,11 +1,12 @@ """Test Home Assistant yaml loader.""" +from collections.abc import Generator import importlib import io import os import pathlib from typing import Any import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import voluptuous as vol @@ -585,6 +586,58 @@ async def test_loading_actual_file_with_syntax_error( await hass.async_add_executor_job(load_yaml_config_file, fixture_path) +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + +@pytest.mark.parametrize( + ("loader_class", "message"), + [ + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + ( + yaml.loader.SafeLineLoader, + "'SafeLineLoader' instead of 'PythonSafeLoader'", + ), + ], +) +async def test_deprecated_loaders( + hass: HomeAssistant, + mock_integration_frame: Mock, + caplog: pytest.LogCaptureFixture, + loader_class, + 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 + + def test_string_annotated(try_both_loaders) -> None: """Test strings are annotated with file + line.""" conf = ( From 208622e8a7cd0d65e1b0537afb5ee787d3744e1e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Nov 2023 18:45:04 +0100 Subject: [PATCH 920/982] Revert "Add Komfovent (#95722)" (#104819) --- .coveragerc | 2 - CODEOWNERS | 2 - .../components/komfovent/__init__.py | 34 ---- homeassistant/components/komfovent/climate.py | 91 --------- .../components/komfovent/config_flow.py | 74 ------- homeassistant/components/komfovent/const.py | 3 - .../components/komfovent/manifest.json | 9 - .../components/komfovent/strings.json | 22 -- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/komfovent/__init__.py | 1 - tests/components/komfovent/conftest.py | 14 -- .../components/komfovent/test_config_flow.py | 189 ------------------ 15 files changed, 454 deletions(-) delete mode 100644 homeassistant/components/komfovent/__init__.py delete mode 100644 homeassistant/components/komfovent/climate.py delete mode 100644 homeassistant/components/komfovent/config_flow.py delete mode 100644 homeassistant/components/komfovent/const.py delete mode 100644 homeassistant/components/komfovent/manifest.json delete mode 100644 homeassistant/components/komfovent/strings.json delete mode 100644 tests/components/komfovent/__init__.py delete mode 100644 tests/components/komfovent/conftest.py delete mode 100644 tests/components/komfovent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f15d36918ec..27aed1e1009 100644 --- a/.coveragerc +++ b/.coveragerc @@ -633,8 +633,6 @@ omit = homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py - homeassistant/components/komfovent/__init__.py - homeassistant/components/komfovent/climate.py homeassistant/components/konnected/__init__.py homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index ec32f941d56..5ecb7d75cc8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -663,8 +663,6 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund -/homeassistant/components/komfovent/ @ProstoSanja -/tests/components/komfovent/ @ProstoSanja /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm diff --git a/homeassistant/components/komfovent/__init__.py b/homeassistant/components/komfovent/__init__.py deleted file mode 100644 index 0366a429b21..00000000000 --- a/homeassistant/components/komfovent/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - -PLATFORMS: list[Platform] = [Platform.CLIMATE] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Komfovent from a config entry.""" - host = entry.data[CONF_HOST] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - _, credentials = komfovent_api.get_credentials(host, username, password) - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - raise ConfigEntryNotReady(f"Unable to connect to {host}: {result}") - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (credentials, settings) - - 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/komfovent/climate.py b/homeassistant/components/komfovent/climate.py deleted file mode 100644 index 2e51fddf4f2..00000000000 --- a/homeassistant/components/komfovent/climate.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Ventilation Units from Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - -HASS_TO_KOMFOVENT_MODES = { - HVACMode.COOL: komfovent_api.KomfoventModes.COOL, - HVACMode.HEAT_COOL: komfovent_api.KomfoventModes.HEAT_COOL, - HVACMode.OFF: komfovent_api.KomfoventModes.OFF, - HVACMode.AUTO: komfovent_api.KomfoventModes.AUTO, -} -KOMFOVENT_TO_HASS_MODES = {v: k for k, v in HASS_TO_KOMFOVENT_MODES.items()} - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Komfovent unit control.""" - credentials, settings = hass.data[DOMAIN][entry.entry_id] - async_add_entities([KomfoventDevice(credentials, settings)], True) - - -class KomfoventDevice(ClimateEntity): - """Representation of a ventilation unit.""" - - _attr_hvac_modes = list(HASS_TO_KOMFOVENT_MODES.keys()) - _attr_preset_modes = [mode.name for mode in komfovent_api.KomfoventPresets] - _attr_supported_features = ClimateEntityFeature.PRESET_MODE - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, - credentials: komfovent_api.KomfoventCredentials, - settings: komfovent_api.KomfoventSettings, - ) -> None: - """Initialize the ventilation unit.""" - self._komfovent_credentials = credentials - self._komfovent_settings = settings - - self._attr_unique_id = settings.serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, settings.serial_number)}, - model=settings.model, - name=settings.name, - serial_number=settings.serial_number, - sw_version=settings.version, - manufacturer="Komfovent", - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new target preset mode.""" - await komfovent_api.set_preset( - self._komfovent_credentials, - komfovent_api.KomfoventPresets[preset_mode], - ) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - await komfovent_api.set_mode( - self._komfovent_credentials, HASS_TO_KOMFOVENT_MODES[hvac_mode] - ) - - async def async_update(self) -> None: - """Get the latest data.""" - result, status = await komfovent_api.get_unit_status( - self._komfovent_credentials - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS or not status: - self._attr_available = False - return - self._attr_available = True - self._attr_preset_mode = status.preset - self._attr_current_temperature = status.temp_extract - self._attr_hvac_mode = KOMFOVENT_TO_HASS_MODES[status.mode] diff --git a/homeassistant/components/komfovent/config_flow.py b/homeassistant/components/komfovent/config_flow.py deleted file mode 100644 index fb5390a30c6..00000000000 --- a/homeassistant/components/komfovent/config_flow.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Config flow for Komfovent integration.""" -from __future__ import annotations - -import logging -from typing import Any - -import komfovent_api -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER = "user" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_USERNAME, default="user"): str, - vol.Required(CONF_PASSWORD): str, - } -) - -ERRORS_MAP = { - komfovent_api.KomfoventConnectionResult.NOT_FOUND: "cannot_connect", - komfovent_api.KomfoventConnectionResult.UNAUTHORISED: "invalid_auth", - komfovent_api.KomfoventConnectionResult.INVALID_INPUT: "invalid_input", -} - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Komfovent.""" - - VERSION = 1 - - def __return_error( - self, result: komfovent_api.KomfoventConnectionResult - ) -> FlowResult: - return self.async_show_form( - step_id=STEP_USER, - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": ERRORS_MAP.get(result, "unknown")}, - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id=STEP_USER, data_schema=STEP_USER_DATA_SCHEMA - ) - - conf_host = user_input[CONF_HOST] - conf_username = user_input[CONF_USERNAME] - conf_password = user_input[CONF_PASSWORD] - - result, credentials = komfovent_api.get_credentials( - conf_host, conf_username, conf_password - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - await self.async_set_unique_id(settings.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=settings.name, data=user_input) diff --git a/homeassistant/components/komfovent/const.py b/homeassistant/components/komfovent/const.py deleted file mode 100644 index a7881a58c41..00000000000 --- a/homeassistant/components/komfovent/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Komfovent integration.""" - -DOMAIN = "komfovent" diff --git a/homeassistant/components/komfovent/manifest.json b/homeassistant/components/komfovent/manifest.json deleted file mode 100644 index cbe00ef8dc5..00000000000 --- a/homeassistant/components/komfovent/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "komfovent", - "name": "Komfovent", - "codeowners": ["@ProstoSanja"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/komfovent", - "iot_class": "local_polling", - "requirements": ["komfovent-api==0.0.3"] -} diff --git a/homeassistant/components/komfovent/strings.json b/homeassistant/components/komfovent/strings.json deleted file mode 100644 index 074754c1fe0..00000000000 --- a/homeassistant/components/komfovent/strings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_input": "Failed to parse provided hostname", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 57503f0ef32..eeee6532792 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -246,7 +246,6 @@ FLOWS = { "kmtronic", "knx", "kodi", - "komfovent", "konnected", "kostal_plenticore", "kraken", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f0af72624f6..2c6d8277309 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2881,12 +2881,6 @@ "config_flow": true, "iot_class": "local_push" }, - "komfovent": { - "name": "Komfovent", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "konnected": { "name": "Konnected.io", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 54a598ad9b1..8e9ad9fb404 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1128,9 +1128,6 @@ kiwiki-client==0.1.1 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82a65044067..bc70a0d4124 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -885,9 +885,6 @@ kegtron-ble==0.4.0 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/komfovent/__init__.py b/tests/components/komfovent/__init__.py deleted file mode 100644 index e5492a52327..00000000000 --- a/tests/components/komfovent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Komfovent integration.""" diff --git a/tests/components/komfovent/conftest.py b/tests/components/komfovent/conftest.py deleted file mode 100644 index d9cb0950c74..00000000000 --- a/tests/components/komfovent/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Common fixtures for the Komfovent tests.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch - -import pytest - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.komfovent.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry diff --git a/tests/components/komfovent/test_config_flow.py b/tests/components/komfovent/test_config_flow.py deleted file mode 100644 index 008d92e36a3..00000000000 --- a/tests/components/komfovent/test_config_flow.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Test the Komfovent config flow.""" -from unittest.mock import AsyncMock, patch - -import komfovent_api -import pytest - -from homeassistant import config_entries -from homeassistant.components.komfovent.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test flow completes as expected.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_authenticating( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow authentication step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_device_info( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow device info download step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_device_already_exists( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test device is not added when it already exists.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - unique_id="test-uid", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.ABORT - assert final_result["reason"] == "already_configured" - - -async def __test_normal_flow(hass: HomeAssistant, flow_id: str) -> FlowResult: - """Test flow completing as expected, no matter what happened before.""" - - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventSettings("test-name", None, None, "test-uid"), - ), - ): - final_result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - return final_result From 93c8618f8ad04490bf2c7cc4e736699b88d9a32c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Nov 2023 19:48:24 +0100 Subject: [PATCH 921/982] Bump version to 2023.12.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1fbd97159a7..9e1fda9865a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index bd1168d11de..c5a1e2705d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b0" +version = "2023.12.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From cc0326548ec7ddc6771537d1a1dc9663bf900d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20H=C3=A4rtel?= <60009336+Haerteleric@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:48:44 +0100 Subject: [PATCH 922/982] Add CB3 descriptor to ZHA manifest (#104071) --- homeassistant/components/zha/manifest.json | 6 ++++++ homeassistant/generated/usb.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index cd53772777a..4c8a58a12cf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -76,6 +76,12 @@ "description": "*conbee*", "known_devices": ["Conbee II"] }, + { + "vid": "0403", + "pid": "6015", + "description": "*conbee*", + "known_devices": ["Conbee III"] + }, { "vid": "10C4", "pid": "8A2A", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f58936caf8d..2fdd032c2dd 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -81,6 +81,12 @@ USB = [ "pid": "0030", "vid": "1CF1", }, + { + "description": "*conbee*", + "domain": "zha", + "pid": "6015", + "vid": "0403", + }, { "description": "*zigbee*", "domain": "zha", From 7ea4e15ff2570f9d2bd07dd6b9affe07248d5df7 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 30 Nov 2023 16:40:41 -0500 Subject: [PATCH 923/982] Late review updates for Blink (#104755) --- homeassistant/components/blink/__init__.py | 4 +- homeassistant/components/blink/services.py | 79 ++++++++----- homeassistant/components/blink/strings.json | 17 +++ tests/components/blink/test_services.py | 118 +++++++++----------- 4 files changed, 122 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 42ad5cabeb7..d83c2686563 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import BlinkUpdateCoordinator -from .services import async_setup_services +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Blink.""" - await async_setup_services(hass) + setup_services(hass) return True diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 8ea0b6c03a4..12ac0d3b859 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -1,8 +1,6 @@ """Services for the Blink integration.""" from __future__ import annotations -import logging - import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -14,7 +12,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -27,56 +25,67 @@ from .const import ( ) from .coordinator import BlinkUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string, } ) SERVICE_SEND_PIN_SCHEMA = vol.Schema( - {vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Optional(CONF_PIN): cv.string} + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_PIN): cv.string, + } ) SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string, } ) -async def async_setup_services(hass: HomeAssistant) -> None: +def setup_services(hass: HomeAssistant) -> None: """Set up the services for the Blink integration.""" - async def collect_coordinators( + def collect_coordinators( device_ids: list[str], ) -> list[BlinkUpdateCoordinator]: - config_entries = list[ConfigEntry]() + config_entries: list[ConfigEntry] = [] registry = dr.async_get(hass) for target in device_ids: device = registry.async_get(target) if device: - device_entries = list[ConfigEntry]() + device_entries: list[ConfigEntry] = [] for entry_id in device.config_entries: entry = hass.config_entries.async_get_entry(entry_id) if entry and entry.domain == DOMAIN: device_entries.append(entry) if not device_entries: - raise HomeAssistantError( - f"Device '{target}' is not a {DOMAIN} device" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device", + translation_placeholders={"target": target, "domain": DOMAIN}, ) config_entries.extend(device_entries) else: raise HomeAssistantError( - f"Device '{target}' not found in device registry" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"target": target}, ) - coordinators = list[BlinkUpdateCoordinator]() + + coordinators: list[BlinkUpdateCoordinator] = [] for config_entry in config_entries: if config_entry.state != ConfigEntryState.LOADED: - raise HomeAssistantError(f"{config_entry.title} is not loaded") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) return coordinators @@ -85,24 +94,36 @@ async def async_setup_services(hass: HomeAssistant) -> None: camera_name = call.data[CONF_NAME] video_path = call.data[CONF_FILENAME] if not hass.config.is_allowed_path(video_path): - _LOGGER.error("Can't write %s, no access to path!", video_path) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": video_path}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: await all_cameras[camera_name].video_to_file(video_path) except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: """Save multiple recent clips to output directory.""" camera_name = call.data[CONF_NAME] clips_dir = call.data[CONF_FILE_PATH] if not hass.config.is_allowed_path(clips_dir): - _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": clips_dir}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: @@ -110,11 +131,15 @@ async def async_setup_services(hass: HomeAssistant) -> None: output_dir=clips_dir ) except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def send_pin(call: ServiceCall): """Call blink to send new pin.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.auth.send_auth_key( coordinator.api, call.data[CONF_PIN], @@ -122,7 +147,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def blink_refresh(call: ServiceCall): """Call blink to refresh info.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.refresh(force_cache=True) # Register all the above services diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index c29c4c765b7..f47f72acb9c 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -101,5 +101,22 @@ } } } + }, + "exceptions": { + "invalid_device": { + "message": "Device '{target}' is not a {domain} device" + }, + "device_not_found": { + "message": "Device '{target}' not found in device registry" + }, + "no_path": { + "message": "Can't write to directory {target}, no access to path!" + }, + "cant_write": { + "message": "Can't write to file" + }, + "not_loaded": { + "message": "{target} is not loaded" + } } } diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 438b47f38c5..ccc326dac1f 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ async def test_refresh_service_calls( assert mock_blink_api.refresh.call_count == 2 - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_REFRESH, @@ -66,8 +66,6 @@ async def test_refresh_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_video_service_calls( hass: HomeAssistant, @@ -90,18 +88,17 @@ async def test_video_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) caplog.clear() @@ -118,7 +115,7 @@ async def test_video_service_calls( ) mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_VIDEO, @@ -130,22 +127,19 @@ async def test_video_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "Can't write image" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=False) @@ -171,18 +165,17 @@ async def test_picture_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} @@ -202,21 +195,20 @@ async def test_picture_service_calls( mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( side_effect=OSError ) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "Can't write recent clips to directory" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_RECENT_CLIPS, @@ -228,8 +220,6 @@ async def test_picture_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_pin_service_calls( hass: HomeAssistant, @@ -259,7 +249,7 @@ async def test_pin_service_calls( ) assert mock_blink_api.auth.send_auth_key.assert_awaited_once - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, @@ -267,8 +257,6 @@ async def test_pin_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -325,7 +313,7 @@ async def test_service_called_with_non_blink_device( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, service, @@ -333,8 +321,6 @@ async def test_service_called_with_non_blink_device( blocking=True, ) - assert f"Device '{device_entry.id}' is not a blink device" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -382,12 +368,10 @@ async def test_service_called_with_unloaded_entry( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, service, parameters, blocking=True, ) - - assert "Mock Title is not loaded" in str(execinfo) From 0d318da9aa481dbc7c3af1ca31d7c1d0b4e0afe2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 20:08:58 +0100 Subject: [PATCH 924/982] Update Matter test fixtures to schema version 5 (#104829) --- .../fixtures/config_entry_diagnostics.json | 68 ++++----- .../config_entry_diagnostics_redacted.json | 68 ++++----- .../nodes/color-temperature-light.json | 114 +++++++------- .../fixtures/nodes/device_diagnostics.json | 76 +++++----- .../matter/fixtures/nodes/dimmable-light.json | 80 +++++----- .../fixtures/nodes/door-lock-with-unbolt.json | 142 +++++++++--------- .../matter/fixtures/nodes/door-lock.json | 142 +++++++++--------- .../fixtures/nodes/eve-contact-sensor.json | 120 +++++++-------- .../fixtures/nodes/extended-color-light.json | 114 +++++++------- .../matter/fixtures/nodes/flow-sensor.json | 12 +- .../fixtures/nodes/generic-switch-multi.json | 25 ++- .../matter/fixtures/nodes/generic-switch.json | 12 +- .../fixtures/nodes/humidity-sensor.json | 12 +- .../matter/fixtures/nodes/light-sensor.json | 12 +- .../fixtures/nodes/occupancy-sensor.json | 12 +- .../fixtures/nodes/on-off-plugin-unit.json | 12 +- .../fixtures/nodes/onoff-light-alt-name.json | 72 ++++----- .../fixtures/nodes/onoff-light-no-name.json | 72 ++++----- .../matter/fixtures/nodes/onoff-light.json | 80 +++++----- .../fixtures/nodes/pressure-sensor.json | 12 +- .../matter/fixtures/nodes/switch-unit.json | 12 +- .../fixtures/nodes/temperature-sensor.json | 12 +- .../matter/fixtures/nodes/thermostat.json | 124 +++++++-------- .../fixtures/nodes/window-covering_full.json | 120 +++++++-------- .../fixtures/nodes/window-covering_lift.json | 120 +++++++-------- .../nodes/window-covering_pa-lift.json | 99 ++++++------ .../nodes/window-covering_pa-tilt.json | 120 +++++++-------- .../fixtures/nodes/window-covering_tilt.json | 120 +++++++-------- 28 files changed, 990 insertions(+), 994 deletions(-) diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 53477792e43..f591709fbda 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -40,11 +40,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -76,8 +76,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -122,8 +122,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -155,14 +155,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -503,19 +503,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -540,20 +540,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 8a67ef0fb63..c85ee4d70e3 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -42,11 +42,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -78,8 +78,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -124,8 +124,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -157,14 +157,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -317,19 +317,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -354,20 +354,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 7552fa833fb..45d1c18635c 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-color-temperature-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 268, - "revision": 1 + "0": 268, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 3abecbdf66f..d95fbe5efa9 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -13,8 +13,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -30,11 +30,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -66,8 +66,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -112,8 +112,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -143,14 +143,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -302,19 +302,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -339,20 +339,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -415,8 +415,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index e14c922857c..7ccc3eef3af 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-dimmable-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json index 6cbd75ab09c..dfa7794f28b 100644 --- a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json +++ b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json index 1477d78aa67..8a3f0fd68dd 100644 --- a/tests/components/matter/fixtures/nodes/door-lock.json +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json index b0eacfb621c..a009796f940 100644 --- a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json @@ -12,16 +12,16 @@ "0/53/47": 0, "0/53/8": [ { - "extAddress": 12872547289273451492, - "rloc16": 1024, - "routerId": 1, - "nextHop": 0, - "pathCost": 0, - "LQIIn": 3, - "LQIOut": 3, - "age": 142, - "allocated": true, - "linkEstablished": true + "0": 12872547289273451492, + "1": 1024, + "2": 1, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 142, + "8": true, + "9": true } ], "0/53/29": 1556, @@ -30,20 +30,20 @@ "0/53/40": 519, "0/53/7": [ { - "extAddress": 12872547289273451492, - "age": 654, - "rloc16": 1024, - "linkFrameCounter": 738, - "mleFrameCounter": 418, - "lqi": 3, - "averageRssi": -50, - "lastRssi": -51, - "frameErrorRate": 5, - "messageErrorRate": 0, - "rxOnWhenIdle": true, - "fullThreadDevice": true, - "fullNetworkData": true, - "isChild": false + "0": 12872547289273451492, + "1": 654, + "2": 1024, + "3": 738, + "4": 418, + "5": 3, + "6": -50, + "7": -51, + "8": 5, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false } ], "0/53/33": 66, @@ -124,9 +124,9 @@ "0/53/16": 0, "0/42/0": [ { - "providerNodeID": 1773685588, - "endpoint": 0, - "fabricIndex": 1 + "1": 1773685588, + "2": 0, + "254": 1 } ], "0/42/65528": [], @@ -140,8 +140,8 @@ "0/48/65532": 0, "0/48/65528": [1, 3, 5], "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], @@ -158,25 +158,25 @@ "0/31/1": [], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/65532": 0, @@ -187,8 +187,8 @@ "0/49/65533": 1, "0/49/1": [ { - "networkID": "Uv50lWMtT7s=", - "connected": true + "0": "Uv50lWMtT7s=", + "1": true } ], "0/49/3": 20, @@ -217,8 +217,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 46, 48, 49, 51, 53, 60, 62, 63], @@ -226,18 +226,18 @@ "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "ieee802154", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YtmXHFJ/dhk=", - "IPv4Addresses": [], - "IPv6Addresses": [ + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "YtmXHFJ/dhk=", + "5": [], + "6": [ "/RG+U41GAABynlpPU50e5g==", "/oAAAAAAAABg2ZccUn92GQ==", "/VL+dJVjAAB1cwmi02rvTA==" ], - "type": 4 + "7": 4 } ], "0/51/65529": [0], @@ -261,8 +261,8 @@ "0/40/6": "**REDACTED**", "0/40/3": "Eve Door", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/2": 4874, "0/40/65532": 0, @@ -302,8 +302,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 21, - "revision": 1 + "0": 21, + "1": 1 } ], "1/29/65528": [], diff --git a/tests/components/matter/fixtures/nodes/extended-color-light.json b/tests/components/matter/fixtures/nodes/extended-color-light.json index f4d83239b6d..d18b76768ca 100644 --- a/tests/components/matter/fixtures/nodes/extended-color-light.json +++ b/tests/components/matter/fixtures/nodes/extended-color-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-extended-color-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 269, - "revision": 1 + "0": 269, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/flow-sensor.json b/tests/components/matter/fixtures/nodes/flow-sensor.json index e1fc2a36585..a8dad202fa1 100644 --- a/tests/components/matter/fixtures/nodes/flow-sensor.json +++ b/tests/components/matter/fixtures/nodes/flow-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-flow-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 774, - "revision": 1 + "0": 774, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic-switch-multi.json index 15c93825307..f564e91a1ce 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch-multi.json +++ b/tests/components/matter/fixtures/nodes/generic-switch-multi.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], @@ -77,17 +77,16 @@ "1/59/65528": [], "1/64/0": [ { - "label": "Label", - "value": "1" + "0": "Label", + "1": "1" } ], - "2/3/65529": [0, 64], "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "2/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "2/29/1": [3, 29, 59], @@ -107,8 +106,8 @@ "2/59/65528": [], "2/64/0": [ { - "label": "Label", - "value": "Fancy Button" + "0": "Label", + "1": "Fancy Button" } ] }, diff --git a/tests/components/matter/fixtures/nodes/generic-switch.json b/tests/components/matter/fixtures/nodes/generic-switch.json index 30763c88e5b..80773915748 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch.json +++ b/tests/components/matter/fixtures/nodes/generic-switch.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], diff --git a/tests/components/matter/fixtures/nodes/humidity-sensor.json b/tests/components/matter/fixtures/nodes/humidity-sensor.json index a1940fc1857..8220c9cf8f8 100644 --- a/tests/components/matter/fixtures/nodes/humidity-sensor.json +++ b/tests/components/matter/fixtures/nodes/humidity-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-humidity-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 775, - "revision": 1 + "0": 775, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/light-sensor.json b/tests/components/matter/fixtures/nodes/light-sensor.json index 93583c34292..c4d84bc7923 100644 --- a/tests/components/matter/fixtures/nodes/light-sensor.json +++ b/tests/components/matter/fixtures/nodes/light-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-light-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 262, - "revision": 1 + "0": 262, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy-sensor.json index d8f2580c2b0..f63dd43362b 100644 --- a/tests/components/matter/fixtures/nodes/occupancy-sensor.json +++ b/tests/components/matter/fixtures/nodes/occupancy-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 263, - "revision": 1 + "0": 263, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index 43ba486bc29..8d523f5443a 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-plugin-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -118,8 +118,8 @@ ], "1/29/0": [ { - "deviceType": 266, - "revision": 1 + "0": 266, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json index f29361da128..3f6e83ca460 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json index 8a1134409a9..18cb68c8926 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index 65ef0be5c8e..eed404ff85d 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/pressure-sensor.json b/tests/components/matter/fixtures/nodes/pressure-sensor.json index a47cda28056..d38ac560ac5 100644 --- a/tests/components/matter/fixtures/nodes/pressure-sensor.json +++ b/tests/components/matter/fixtures/nodes/pressure-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-pressure-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 773, - "revision": 1 + "0": 773, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch-unit.json index ceed22d2524..e16f1e406ec 100644 --- a/tests/components/matter/fixtures/nodes/switch-unit.json +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 99999, - "revision": 1 + "0": 99999, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-switch-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -96,8 +96,8 @@ "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 9999999, - "revision": 1 + "0": 9999999, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature-sensor.json index c7d372ac2d7..0abb366f81b 100644 --- a/tests/components/matter/fixtures/nodes/temperature-sensor.json +++ b/tests/components/matter/fixtures/nodes/temperature-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 770, - "revision": 1 + "0": 770, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index 85ac42e5429..a7abff41331 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -8,8 +8,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 54, 60, 62, 63, 64], @@ -22,18 +22,18 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -64,8 +64,8 @@ "0/40/17": true, "0/40/18": "3D06D025F9E026A0", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -86,8 +86,8 @@ "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -100,8 +100,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -122,18 +122,18 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "3FR1X7qs", - "IPv4Addresses": ["wKgI7g=="], - "IPv6Addresses": [ + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "3FR1X7qs", + "5": ["wKgI7g=="], + "6": [ "/oAAAAAAAADeVHX//l+6rA==", "JA4DsgZ9jUDeVHX//l+6rA==", "/UgvJAe/AADeVHX//l+6rA==" ], - "type": 1 + "7": 1 } ], "0/51/1": 4, @@ -182,32 +182,32 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", - "vendorID": 4996, - "fabricID": 1, - "nodeID": 1425709672, - "label": "", - "fabricIndex": 1 + "1": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", + "2": 4996, + "3": 1, + "4": 1425709672, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", - "vendorID": 65521, - "fabricID": 1, - "nodeID": 4, - "label": "", - "fabricIndex": 2 + "1": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -233,20 +233,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -275,8 +275,8 @@ "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 769, - "revision": 1 + "0": 769, + "1": 1 } ], "1/29/1": [3, 4, 6, 29, 30, 64, 513, 514, 516], @@ -295,20 +295,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window-covering_full.json index feb75409526..fc6efe2077c 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_full.json +++ b/tests/components/matter/fixtures/nodes/window-covering_full.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-full-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window-covering_lift.json index afc2a2f734f..9c58869e988 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_lift.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-lift-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json index 8d3335bbd6c..fe970b6ed6b 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "7630EF9998EDF03C", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -117,8 +117,8 @@ "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -131,8 +131,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -153,17 +153,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "hPcDB5/k", - "IPv4Addresses": ["wKgIhg=="], - "IPv6Addresses": [ - "/oAAAAAAAACG9wP//gef5A==", - "JA4DsgZ+bsCG9wP//gef5A==" - ], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "hPcDB5/k", + "5": ["wKgIhg=="], + "6": ["/oAAAAAAAACG9wP//gef5A==", "JA4DsgZ+bsCG9wP//gef5A=="], + "7": 1 } ], "0/51/1": 35, @@ -201,19 +198,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 2 + "1": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -239,20 +236,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -281,8 +278,8 @@ "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 514, - "revision": 1 + "0": 514, + "1": 1 } ], "1/29/1": [3, 4, 29, 30, 64, 65, 258], @@ -301,20 +298,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json index 44347dbd964..92a1d820d2e 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock_pa_tilt_window_covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window-covering_tilt.json index a33e0f24c3f..144348b5c76 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-tilt-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], From 367bbf57094459f025d69edb776bcaef226e9228 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 22:43:34 +0100 Subject: [PATCH 925/982] Use deprecated_class decorator in deprecated YAML loader classes (#104835) --- homeassistant/util/yaml/loader.py | 20 +++----------------- tests/util/yaml/test_init.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index e8f4a734bdb..275a51cd760 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -23,7 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.frame import report +from homeassistant.helpers.deprecation import deprecated_class from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -137,17 +137,10 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets +@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - report( - "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - FastSafeLoader.__init__(*args, **kwargs) - class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -158,17 +151,10 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets +@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - report( - "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - PythonSafeLoader.__init__(*args, **kwargs) - LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index c4e5c58e235..3a2d9b3734d 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -590,7 +590,7 @@ async def test_loading_actual_file_with_syntax_error( def mock_integration_frame() -> Generator[Mock, None, None]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", + filename="/home/paulus/.homeassistant/custom_components/hue/light.py", lineno="23", line="self.light.is_on", ) @@ -614,12 +614,12 @@ def mock_integration_frame() -> Generator[Mock, None, None]: @pytest.mark.parametrize( - ("loader_class", "message"), + ("loader_class", "new_class"), [ - (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + (yaml.loader.SafeLoader, "FastSafeLoader"), ( yaml.loader.SafeLineLoader, - "'SafeLineLoader' instead of 'PythonSafeLoader'", + "PythonSafeLoader", ), ], ) @@ -628,14 +628,17 @@ async def test_deprecated_loaders( mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, loader_class, - message: str, + new_class: 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 + assert ( + f"{loader_class.__name__} was called from hue, this is a deprecated class. " + f"Use {new_class} instead" + ) in caplog.text def test_string_annotated(try_both_loaders) -> None: From 11db0ab1e16dd868730411b0405f5cde5e5d0eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Nov 2023 22:39:54 +0100 Subject: [PATCH 926/982] Bump Mill library (#104836) --- 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 cb0ba4522bf..7bb78eb05e7 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.6", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.7", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e9ad9fb404..b208d1ca486 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1246,7 +1246,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc70a0d4124..892373c2c5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -970,7 +970,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 From 262e59f29323882727e2af27143152538bc7714a Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 30 Nov 2023 16:21:34 -0500 Subject: [PATCH 927/982] Fix Harmony switch removal version (#104838) --- homeassistant/components/harmony/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index a3c588c06bb..6b833df9720 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -27,7 +27,7 @@ async def async_setup_entry( hass, DOMAIN, "deprecated_switches", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches", @@ -91,7 +91,7 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): self.hass, DOMAIN, f"deprecated_switches_{self.entity_id}_{item}", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches_entity", From d67d2d9566ba6c425c77757bcb9c099157cd389c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Nov 2023 23:42:51 +0100 Subject: [PATCH 928/982] Filter out zero readings for DSMR enery sensors (#104843) --- homeassistant/components/dsmr/sensor.py | 4 ++++ tests/components/dsmr/test_sensor.py | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index f56e2c3ed33..0fa04dee489 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -799,6 +799,10 @@ class DSMREntity(SensorEntity): float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) ) + # Make sure we do not return a zero value for an energy sensor + if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: + return None + return value @staticmethod diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 1b7f8efb201..0c71525be48 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -10,6 +10,8 @@ from decimal import Decimal from itertools import chain, repeat from unittest.mock import DEFAULT, MagicMock +import pytest + from homeassistant import config_entries from homeassistant.components.sensor import ( ATTR_OPTIONS, @@ -22,6 +24,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -308,7 +311,17 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) -async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: +@pytest.mark.parametrize( + ("value", "state"), + [ + (Decimal(745.690), "745.69"), + (Decimal(745.695), "745.695"), + (Decimal(0.000), STATE_UNKNOWN), + ], +) +async def test_v5_meter( + hass: HomeAssistant, dsmr_connection_fixture, value: Decimal, state: str +) -> None: """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -335,7 +348,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": value, "unit": "m3"}, ], ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( @@ -371,7 +384,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == "745.695" + assert gas_consumption.state == state assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) From 074bcc8adc8b845a6cb8fb09e8de76e15e8e5f18 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 1 Dec 2023 15:58:15 +0100 Subject: [PATCH 929/982] Fix handling of unrecognized mimetypes in Synology DSM photos integration (#104848) --- homeassistant/components/synology_dsm/media_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 16db365f708..3f30fe9b4e9 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -153,8 +153,7 @@ class SynologyPhotosMediaSource(MediaSource): ret = [] for album_item in album_items: mime_type, _ = mimetypes.guess_type(album_item.file_name) - assert isinstance(mime_type, str) - if mime_type.startswith("image/"): + if isinstance(mime_type, str) and mime_type.startswith("image/"): # Force small small thumbnails album_item.thumbnail_size = "sm" ret.append( From 78cf9f2a0186ce8a1433a0144f48a85231c638f3 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:44:52 +0100 Subject: [PATCH 930/982] Lifx, Lutron: add host field description (#104855) --- homeassistant/components/lifx/strings.json | 3 +++ homeassistant/components/lutron_caseta/strings.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c327081fabd..21f3b3fe52b 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LIFX device." } }, "pick_device": { diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index b5ec175d1c9..0fb906f097f 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -11,6 +11,9 @@ "description": "Enter the IP address of the device.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Lutron Caseta Smart Bridge." } }, "link": { From 1378abab357e3686832490739611a5ac34f87d79 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:46:49 +0100 Subject: [PATCH 931/982] Modern Forms to MyStrom: add host field description (#104856) --- homeassistant/components/modern_forms/strings.json | 3 +++ homeassistant/components/moehlenhoff_alpha2/strings.json | 3 +++ homeassistant/components/mutesync/strings.json | 3 +++ homeassistant/components/mystrom/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index dd47ef721af..e6d0f6a2206 100644 --- a/homeassistant/components/modern_forms/strings.json +++ b/homeassistant/components/modern_forms/strings.json @@ -6,6 +6,9 @@ "description": "Set up your Modern Forms fan to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Modern Forms fan." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/moehlenhoff_alpha2/strings.json b/homeassistant/components/moehlenhoff_alpha2/strings.json index 3347b2f318c..d15ec9f89eb 100644 --- a/homeassistant/components/moehlenhoff_alpha2/strings.json +++ b/homeassistant/components/moehlenhoff_alpha2/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Möhlenhoff Alpha2 system." } } }, diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json index 2a3cca666ee..b0826384899 100644 --- a/homeassistant/components/mutesync/strings.json +++ b/homeassistant/components/mutesync/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your mutesync device." } } }, diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index a485a58f5a6..9ebd1c36df0 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your myStrom device." } } }, From 1d04fcc485944cbfa7ffb6478ad65ec587aadd60 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:38:52 +0100 Subject: [PATCH 932/982] Nanoleaf to Nut: add host field description (#104857) Co-authored-by: starkillerOG --- homeassistant/components/nanoleaf/strings.json | 3 +++ homeassistant/components/netgear/strings.json | 3 +++ homeassistant/components/nfandroidtv/strings.json | 3 +++ homeassistant/components/nuki/strings.json | 3 +++ homeassistant/components/nut/strings.json | 5 ++++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index 80eb2ded7d0..13e7c9a11a3 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Nanoleaf device." } }, "link": { diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 6b4883b8ce3..9f3b1aeec9e 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Netgear device. For example: '192.168.1.1'." } } }, diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index fdc9f01d343..cde02327712 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } } }, diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index eb380cabd04..216b891ac31 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "token": "[%key:common::config_flow::data::access_token%]", "encrypt_token": "Use an encrypted token for authentication." + }, + "data_description": { + "host": "The hostname or IP address of your Nuki bridge. For example: 192.168.1.25." } }, "reauth_confirm": { diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 2827911a3aa..7347744d56f 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -2,12 +2,15 @@ "config": { "step": { "user": { - "title": "Connect to the NUT server", + "description": "Connect to the NUT server", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your NUT server." } }, "ups": { From 42982de22368649fdc06562febff666780b90cce Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:13:48 +0100 Subject: [PATCH 933/982] Obihai to OpenGarage: add host field description (#104858) Co-authored-by: Jan Bouwhuis --- homeassistant/components/obihai/strings.json | 6 ++++++ homeassistant/components/octoprint/strings.json | 3 +++ homeassistant/components/onewire/strings.json | 3 +++ homeassistant/components/onvif/strings.json | 3 +++ homeassistant/components/opengarage/strings.json | 3 +++ 5 files changed, 18 insertions(+) diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 823bc2e1b8d..f21b4b3706d 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Obihai device." } }, "dhcp_confirm": { @@ -14,6 +17,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::obihai::config::step::user::data_description::host%]" } } }, diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index c6dbfe6f9c4..63d9753ee1d 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -10,6 +10,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your printer." } }, "reauth_confirm": { diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9e4120b68b2..753f244cfe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -12,6 +12,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your 1-Wire device." + }, "title": "Set server details" } } diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index cabab347264..5a36b89688a 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -36,6 +36,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "host": "The hostname or IP address of your ONVIF device." + }, "title": "Configure ONVIF device" }, "configure_profile": { diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index ba4521d4dcf..f19b458cd0f 100644 --- a/homeassistant/components/opengarage/strings.json +++ b/homeassistant/components/opengarage/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your OpenGarage device." } } }, From f194ffcd52d4f1860ed3edb8705902e649c6d20a Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:18:34 +0100 Subject: [PATCH 934/982] Ping to Qnap: add host field description (#104859) --- homeassistant/components/ping/strings.json | 3 +++ homeassistant/components/progettihwsw/strings.json | 3 +++ homeassistant/components/qnap/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 31441df7736..12bc1d25c7a 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "count": "Ping count" + }, + "data_description": { + "host": "The hostname or IP address of the device you want to ping." } } }, diff --git a/homeassistant/components/progettihwsw/strings.json b/homeassistant/components/progettihwsw/strings.json index bb98d565594..d50c6f8d4e3 100644 --- a/homeassistant/components/progettihwsw/strings.json +++ b/homeassistant/components/progettihwsw/strings.json @@ -13,6 +13,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your ProgettiHWSW board." } }, "relay_modes": { diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index a5fa3c8a897..d535b9f0e87 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your QNAP device." } } }, From 9827ba7e60ff5ff8c5296dd69ad1dc6dee44e140 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:37:51 +0100 Subject: [PATCH 935/982] Radio Thermostat to Renson: add host field description (#104860) --- homeassistant/components/radiotherm/strings.json | 3 +++ homeassistant/components/rainbird/strings.json | 3 +++ homeassistant/components/rainforest_eagle/strings.json | 3 +++ homeassistant/components/renson/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index 693811f59ab..e76bd2d3f2d 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Radio Thermostat." } }, "confirm": { diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6046189ddc4..ea0d64f6208 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Rain Bird device." } } }, diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 58c7f6bd795..7b5054bfb0f 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", "install_code": "Installation Code" + }, + "data_description": { + "host": "The hostname or IP address of your Rainforest gateway." } } }, diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index d6d03ed1c44..8aa7c6244ea 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Renson Endura delta device." } } }, From 0dc157dc31c946f82c2927311b9f6f42da5fbe85 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:37:02 +0100 Subject: [PATCH 936/982] Reolink to Ruckus: add host field description (#104861) Co-authored-by: starkillerOG --- homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/rfxtrx/strings.json | 3 +++ homeassistant/components/roomba/strings.json | 6 ++++++ homeassistant/components/ruckus_unleashed/strings.json | 3 +++ 4 files changed, 15 insertions(+) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5b26d70b657..5a27f0e38cb 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -10,6 +10,9 @@ "use_https": "Enable HTTPS", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'." } }, "reauth_confirm": { diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 85ddf559cf5..9b99553d3f0 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -19,6 +19,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your RFXCOM RFXtrx device." + }, "title": "Select connection address" }, "setup_serial": { diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index f1816d58613..654c1b7fdfc 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -7,6 +7,9 @@ "description": "Select a Roomba or Braava.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "manual": { @@ -14,6 +17,9 @@ "description": "No Roomba or Braava have been discovered on your network.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "link": { diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index 769cde67d7a..65a39e5e218 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ruckus access point." } } }, From 0cf4c6e568540371de83386ca342f9d244bd7302 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:45:53 +0100 Subject: [PATCH 937/982] SamsungTV to Snapcast: add host field description (#104862) --- homeassistant/components/samsungtv/strings.json | 3 +++ homeassistant/components/sfr_box/strings.json | 3 +++ homeassistant/components/sma/strings.json | 3 +++ homeassistant/components/snapcast/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4fb..c9d08f756d0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } }, "confirm": { diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 7ea18304164..6f0001e97ce 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -26,6 +26,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, + "data_description": { + "host": "The hostname or IP address of your SFR device." + }, "description": "Setting the credentials is optional, but enables additional functionality." } } diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index f5dc6c16c88..16e5d7408c4 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -19,6 +19,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "The hostname or IP address of your SMA device." + }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" } diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 0d51c7543f1..b5673910595 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your Snapcast server." + }, "title": "[%key:common::action::connect%]" } }, From 8fd9761e7d182dba9c97381f557788446276fc89 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:48:58 +0100 Subject: [PATCH 938/982] Solar-Log to Soundtouch: add host field description (#104863) --- homeassistant/components/solarlog/strings.json | 3 +++ homeassistant/components/soma/strings.json | 6 ++++-- homeassistant/components/somfy_mylink/strings.json | 3 +++ homeassistant/components/soundtouch/strings.json | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 62e923a766d..5f5e2ae7a5f 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "The prefix to be used for your Solar-Log sensors" + }, + "data_description": { + "host": "The hostname or IP address of your Solar-Log device." } } }, diff --git a/homeassistant/components/soma/strings.json b/homeassistant/components/soma/strings.json index 931a33fff56..abf87b3dde2 100644 --- a/homeassistant/components/soma/strings.json +++ b/homeassistant/components/soma/strings.json @@ -16,8 +16,10 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your SOMA Connect.", - "title": "SOMA Connect" + "data_description": { + "host": "The hostname or IP address of your SOMA Connect." + }, + "description": "Please enter connection settings of your SOMA Connect." } } } diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index 2609e8d893e..90489c0ba34 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "system_id": "System ID" + }, + "data_description": { + "host": "The hostname or IP address of your Somfy MyLink hub." } } }, diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 7af95aab38c..9fc11f7788a 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bose SoundTouch device." } }, "zeroconf_confirm": { From 39026e3b53fb940edd01bc329ebb003c84cae6fd Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 1 Dec 2023 12:26:18 +0100 Subject: [PATCH 939/982] Reolink schedule update after firmware update (#104867) --- homeassistant/components/reolink/update.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index a75af46e81e..ffd429e92ad 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -1,6 +1,7 @@ """Update entities for Reolink devices.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any, Literal @@ -13,9 +14,10 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from . import ReolinkData from .const import DOMAIN @@ -23,6 +25,8 @@ from .entity import ReolinkBaseCoordinatorEntity LOGGER = logging.getLogger(__name__) +POLL_AFTER_INSTALL = 120 + async def async_setup_entry( hass: HomeAssistant, @@ -51,6 +55,7 @@ class ReolinkUpdateEntity( super().__init__(reolink_data, reolink_data.firmware_coordinator) self._attr_unique_id = f"{self._host.unique_id}" + self._cancel_update: CALLBACK_TYPE | None = None @property def installed_version(self) -> str | None: @@ -100,3 +105,16 @@ class ReolinkUpdateEntity( ) from err finally: self.async_write_ha_state() + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_future + ) + + async def _async_update_future(self, now: datetime | None = None) -> None: + """Request update.""" + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + await super().async_will_remove_from_hass() + if self._cancel_update is not None: + self._cancel_update() From 555e413edbd103cd287fda694b0462b5a587473f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 11:52:54 +0100 Subject: [PATCH 940/982] T-add host field description (#104871) --- homeassistant/components/tellduslive/strings.json | 4 +++- homeassistant/components/tesla_wall_connector/strings.json | 3 +++ homeassistant/components/tplink/strings.json | 3 +++ homeassistant/components/tplink_omada/strings.json | 4 +++- homeassistant/components/tradfri/strings.json | 3 +++ homeassistant/components/twinkly/strings.json | 3 +++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 1dbea7a0e6c..16c847f0077 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -18,7 +18,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, - "title": "Pick endpoint." + "data_description": { + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + } } } }, diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 982894eb17c..97bac988d16 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -6,6 +6,9 @@ "title": "Configure Tesla Wall Connector", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tesla Wall Connector." } } }, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 750d422cd0d..3b4024c07b4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TP-Link device." } }, "pick_device": { diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 6da32cd0c1a..04fa6d162d3 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -8,7 +8,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "TP-Link Omada Controller", + "data_description": { + "host": "URL of the management interface of your TP-Link Omada controller." + }, "description": "Enter the connection details for the Omada controller. Cloud controllers aren't supported." }, "site": { diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 0a9a86bd23a..69a28a567ab 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "security_code": "Security Code" + }, + "data_description": { + "host": "Hostname or IP address of your Trådfri gateway." } } }, diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index 9b4c8ebd778..88bc67abbbd 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Twinkly device." } }, "discovery_confirm": { From 9181d655f98f2ae0f6dd229dfc82d63fc32cbdf3 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:10:22 +0100 Subject: [PATCH 941/982] U-V add host field description (#104872) Co-authored-by: Simone Chemelli --- homeassistant/components/unifi/strings.json | 3 +++ homeassistant/components/unifiprotect/strings.json | 3 +++ homeassistant/components/v2c/strings.json | 3 +++ homeassistant/components/vallox/strings.json | 3 +++ homeassistant/components/venstar/strings.json | 5 ++++- homeassistant/components/vilfo/strings.json | 3 +++ homeassistant/components/vizio/strings.json | 4 +++- homeassistant/components/vlc_telnet/strings.json | 3 +++ homeassistant/components/vodafone_station/strings.json | 3 +++ homeassistant/components/volumio/strings.json | 3 +++ 10 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 9c609ca8c07..ba426c2f08a 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "site": "Site ID", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Network." } } }, diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 73ac6e08c17..a345a504c42 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -11,6 +11,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Protect device." } }, "reauth_confirm": { diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index a0cf3aae03a..dafdd597e77 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address fo your V2C Trydan EVSE." } } }, diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index acc6a31f158..e3ade9a55c4 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vallox device." } } }, diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index a844adc2156..92dfac211fb 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -2,13 +2,16 @@ "config": { "step": { "user": { - "title": "Connect to the Venstar Thermostat", + "description": "Connect to the Venstar thermostat", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "pin": "[%key:common::config_flow::data::pin%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your Venstar thermostat." } } }, diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index d559e3a6716..f2c4c38780b 100644 --- a/homeassistant/components/vilfo/strings.json +++ b/homeassistant/components/vilfo/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vilfo router." } } }, diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 0ff64eeda53..6091cd72f3f 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -2,13 +2,15 @@ "config": { "step": { "user": { - "title": "VIZIO SmartCast Device", "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process.", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "device_class": "Device Type", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your VIZIO SmartCast device." } }, "pair_tv": { diff --git a/homeassistant/components/vlc_telnet/strings.json b/homeassistant/components/vlc_telnet/strings.json index 3a22bd06602..c0cacc734d3 100644 --- a/homeassistant/components/vlc_telnet/strings.json +++ b/homeassistant/components/vlc_telnet/strings.json @@ -14,6 +14,9 @@ "port": "[%key:common::config_flow::data::port%]", "password": "[%key:common::config_flow::data::password%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your VLC media player." } }, "hassio_confirm": { diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index aaaa27a3614..fab266ac47f 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Vodafone Station." } } }, diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json index ba283a3af37..32552ad7386 100644 --- a/homeassistant/components/volumio/strings.json +++ b/homeassistant/components/volumio/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your Volumio media player." } }, "discovery_confirm": { From cda7863a451c878f74a8bcfd109639152c21bb0a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 09:36:41 +0100 Subject: [PATCH 942/982] Link second Hue host field description (#104885) --- homeassistant/components/hue/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 122cb489d26..114f501d7a3 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -16,7 +16,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your Hue bridge." + "host": "[%key:component::hue::config::step::init::data_description::host%]" } }, "link": { From 380e71d1b2a3bc3a0b0dcc7b76c5f2b187f72bfa Mon Sep 17 00:00:00 2001 From: Patrick Decat Date: Mon, 4 Dec 2023 09:45:59 +0100 Subject: [PATCH 943/982] Fix incompatible 'measurement' state and 'volume' device class warnings in Overkiz (#104896) --- homeassistant/components/overkiz/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 41c2f4d1a92..0bb9043c040 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -100,7 +100,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Water volume estimation at 40 °C", icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.VOLUME_STORAGE, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -110,7 +110,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.IO_OUTLET_ENGINE, From e1142e2ad8410a9498e18ec9e10ca4b29fd22603 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Dec 2023 19:28:56 +0100 Subject: [PATCH 944/982] Fix dsmr zero reconnect interval option could crash HA (#104900) * Fix dsmr zero interval option could crash HA * No change change the options --- homeassistant/components/dsmr/const.py | 1 - homeassistant/components/dsmr/sensor.py | 9 ++------- tests/components/dsmr/test_config_flow.py | 1 - tests/components/dsmr/test_init.py | 1 - tests/components/dsmr/test_mbus_migration.py | 2 -- tests/components/dsmr/test_sensor.py | 18 ++---------------- 6 files changed, 4 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index ec0623a9ed6..45332546195 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,7 +12,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_PROTOCOL = "protocol" -CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 0fa04dee489..b128f9d3baa 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -48,7 +48,6 @@ from .const import ( CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, - CONF_RECONNECT_INTERVAL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -647,9 +646,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except (serial.serialutil.SerialException, OSError): # Log any error while establishing connection and drop to retry @@ -663,9 +660,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except CancelledError: # Reflect disconnect state in devices state by setting an # None telegram resulting in `unavailable` states diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 55395b92270..5c34fbd9e35 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -475,7 +475,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 512e0822016..231cd65d768 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -99,7 +99,6 @@ async def test_migrate_unique_id( "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 493fd93259f..99513b9a2a8 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -30,7 +30,6 @@ async def test_migrate_gas_to_mbus( "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, @@ -128,7 +127,6 @@ async def test_migrate_gas_to_mbus_exists( "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 0c71525be48..d3bfabdc0c6 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -52,7 +52,6 @@ async def test_default_setup( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -190,7 +189,6 @@ async def test_setup_only_energy( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", } entry_options = { @@ -246,7 +244,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "4", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -335,7 +332,6 @@ async def test_v5_meter( "port": "/dev/ttyUSB0", "dsmr_version": "5", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -411,7 +407,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> "port": "/dev/ttyUSB0", "dsmr_version": "5L", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -515,7 +510,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -717,7 +711,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -880,7 +873,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -992,7 +984,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1047,7 +1038,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5S", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1122,7 +1112,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1196,7 +1185,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "dsmr_version": "2.2", "protocol": "dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1224,7 +1212,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1242,6 +1229,7 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - assert connection_factory.call_args_list[0][0][1] == "1234" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_connection_errors_retry( hass: HomeAssistant, dsmr_connection_fixture ) -> None: @@ -1252,7 +1240,6 @@ async def test_connection_errors_retry( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1281,6 +1268,7 @@ async def test_connection_errors_retry( assert first_fail_connection_factory.call_count >= 2, "connecting not retried" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" from dsmr_parser.obis_references import ( @@ -1295,7 +1283,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1378,7 +1365,6 @@ async def test_gas_meter_providing_energy_reading( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } From f5fae54c3227695eb32a1dc117e093039970a7eb Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 2 Dec 2023 16:35:52 +0100 Subject: [PATCH 945/982] Fix get_events name in calendar strings (#104902) --- homeassistant/components/calendar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 57450000199..78b8407240c 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -73,7 +73,7 @@ } }, "get_events": { - "name": "Get event", + "name": "Get events", "description": "Get events on a calendar within a time range.", "fields": { "start_date_time": { From b53b1ab614e02cf419832d9492129ea532f0db5b Mon Sep 17 00:00:00 2001 From: Alex Thompson Date: Sun, 3 Dec 2023 16:13:26 -0500 Subject: [PATCH 946/982] Fix Lyric HVAC mode reset on temperature change (#104910) * Fix Lyric HVAC mode reset on temperature change * Reduce code duplication * Revert additional bugfix Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/lyric/climate.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index d0bad55ff14..f01e4c4fe55 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -324,6 +324,15 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): "Could not find target_temp_low and/or target_temp_high in" " arguments" ) + + # If the device supports "Auto" mode, don't pass the mode when setting the + # temperature + mode = ( + None + if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL + else HVAC_MODES[device.changeableValues.heatCoolMode] + ) + _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) try: await self._update_thermostat( @@ -331,7 +340,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): device, coolSetpoint=target_temp_high, heatSetpoint=target_temp_low, - mode=HVAC_MODES[device.changeableValues.heatCoolMode], + mode=mode, ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) From 214f21412254ca6cf334dcef3546c0955dd238a1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Dec 2023 11:57:48 +0100 Subject: [PATCH 947/982] Only raise issue if switch used in Logitech Harmony Hub (#104941) --- homeassistant/components/harmony/switch.py | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index 6b833df9720..2d072f11f2c 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -23,15 +23,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activity switches.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_switches", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_switches", - ) data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] activities = data.activities @@ -65,10 +56,28 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_start_activity(self._activity_name) async def async_turn_off(self, **kwargs: Any) -> None: """Stop this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_power_off() async def async_added_to_hass(self) -> None: From cd86318b4be20dbee50b9bac5e771fbb8db57fe1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 4 Dec 2023 10:44:29 +0100 Subject: [PATCH 948/982] Do not fail if Reolink ONVIF cannot be connected (#104947) --- homeassistant/components/reolink/host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f6eb4cb0e55..11cf8f665ad 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -163,7 +163,7 @@ class ReolinkHost: if self._onvif_push_supported: try: await self.subscribe() - except NotSupportedError: + except ReolinkError: self._onvif_push_supported = False self.unregister_webhook() await self._api.unsubscribe() From 63ed4b0769337743ddc870b3feb2347b0fecbd93 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:13:27 +0100 Subject: [PATCH 949/982] Bump bimmer-connected to 0.14.6 (#104961) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 1ebf52e52ae..854a2f87410 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.14.5"] + "requirements": ["bimmer-connected[china]==0.14.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index b208d1ca486..2967035208a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,7 +526,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.5 +bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 892373c2c5f..e5c4936f6f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.5 +bimmer-connected[china]==0.14.6 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 From 204cc20bc2aa7d90ffc4d4aaab302e6a54203a1e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 02:06:01 +0100 Subject: [PATCH 950/982] Do not allow smtp to access insecure files (#104972) --- homeassistant/components/smtp/notify.py | 30 ++++++++++++++++------- tests/components/smtp/test_notify.py | 32 ++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6b960409305..02a5a6408b6 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -8,6 +8,7 @@ from email.mime.text import MIMEText import email.utils import logging import os +from pathlib import Path import smtplib import voluptuous as vol @@ -193,10 +194,15 @@ class MailNotificationService(BaseNotificationService): if data := kwargs.get(ATTR_DATA): if ATTR_HTML in data: msg = _build_html_msg( - message, data[ATTR_HTML], images=data.get(ATTR_IMAGES, []) + self.hass, + message, + data[ATTR_HTML], + images=data.get(ATTR_IMAGES, []), ) else: - msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES, [])) + msg = _build_multipart_msg( + self.hass, message, images=data.get(ATTR_IMAGES, []) + ) else: msg = _build_text_msg(message) @@ -241,13 +247,21 @@ def _build_text_msg(message): return MIMEText(message) -def _attach_file(atch_name, content_id=""): +def _attach_file(hass, atch_name, content_id=""): """Create a message attachment. If MIMEImage is successful and content_id is passed (HTML), add images in-line. Otherwise add them as attachments. """ try: + file_path = Path(atch_name).parent + if not hass.config.is_allowed_path(str(file_path)): + _LOGGER.warning( + "'%s' is not secure to load data from, ignoring attachment '%s'!", + file_path, + atch_name, + ) + return with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: @@ -277,22 +291,22 @@ def _attach_file(atch_name, content_id=""): return attachment -def _build_multipart_msg(message, images): +def _build_multipart_msg(hass, message, images): """Build Multipart message with images as attachments.""" - _LOGGER.debug("Building multipart email with image attachment(s)") + _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)") msg = MIMEMultipart() body_txt = MIMEText(message) msg.attach(body_txt) for atch_name in images: - attachment = _attach_file(atch_name) + attachment = _attach_file(hass, atch_name) if attachment: msg.attach(attachment) return msg -def _build_html_msg(text, html, images): +def _build_html_msg(hass, text, html, images): """Build Multipart message with in-line images and rich HTML (UTF-8).""" _LOGGER.debug("Building HTML rich email") msg = MIMEMultipart("related") @@ -303,7 +317,7 @@ def _build_html_msg(text, html, images): for atch_name in images: name = os.path.basename(atch_name) - attachment = _attach_file(atch_name, name) + attachment = _attach_file(hass, atch_name, name) if attachment: msg.attach(attachment) return msg diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index bca5a5674df..06110a3e5dc 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,4 +1,5 @@ """The tests for the notify smtp platform.""" +from pathlib import Path import re from unittest.mock import patch @@ -132,15 +133,44 @@ EMAIL_DATA = [ ], ) def test_send_message( - message_data, data, content_type, hass: HomeAssistant, message + hass: HomeAssistant, message_data, data, content_type, message ) -> None: """Verify if we can send messages of all types correctly.""" sample_email = "" + message.hass = hass + hass.config.allowlist_external_dirs.add(Path("tests/testing_config").resolve()) with patch("email.utils.make_msgid", return_value=sample_email): result, _ = message.send_message(message_data, data=data) assert content_type in result +@pytest.mark.parametrize( + ("message_data", "data", "content_type"), + [ + ( + "Test msg", + {"images": ["tests/testing_config/notify/test.jpg"]}, + "Content-Type: multipart/mixed", + ), + ], +) +def test_sending_insecure_files_fails( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + message_data, + data, + content_type, + message, +) -> None: + """Verify if we cannot send messages with insecure attachments.""" + sample_email = "" + message.hass = hass + with patch("email.utils.make_msgid", return_value=sample_email): + result, _ = message.send_message(message_data, data=data) + assert content_type in result + assert "test.jpg' is not secure to load data from, ignoring attachment" + + def test_send_text_message(hass: HomeAssistant, message) -> None: """Verify if we can send simple text message.""" expected = ( From 64f7855b9430cb2395373229a9b272f0f3cb504c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 11:48:29 +0100 Subject: [PATCH 951/982] Raise on smtp notification if attachment is not allowed (#104981) * Raise smtp notification if attachment not allowed * Pass url as placeholder * Use variable in err message * Add allow_list as placeholder --- homeassistant/components/smtp/notify.py | 26 +++++++++++++++++----- homeassistant/components/smtp/strings.json | 5 +++++ tests/components/smtp/test_notify.py | 17 ++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 02a5a6408b6..dcc2f49db0f 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -32,6 +32,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -255,13 +256,26 @@ def _attach_file(hass, atch_name, content_id=""): """ try: file_path = Path(atch_name).parent - if not hass.config.is_allowed_path(str(file_path)): - _LOGGER.warning( - "'%s' is not secure to load data from, ignoring attachment '%s'!", - file_path, - atch_name, + if os.path.exists(file_path) and not hass.config.is_allowed_path( + str(file_path) + ): + allow_list = "allowlist_external_dirs" + file_name = os.path.basename(atch_name) + url = "https://www.home-assistant.io/docs/configuration/basic/" + raise ServiceValidationError( + f"Cannot send email with attachment '{file_name} " + f"from directory '{file_path} which is not secure to load data from. " + f"Only folders added to `{allow_list}` are accessible. " + f"See {url} for more information.", + translation_domain=DOMAIN, + translation_key="remote_path_not_allowed", + translation_placeholders={ + "allow_list": allow_list, + "file_path": file_path, + "file_name": file_name, + "url": url, + }, ) - return with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index b711c2f2009..38dd81ac196 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -4,5 +4,10 @@ "name": "[%key:common::action::reload%]", "description": "Reloads smtp notify services." } + }, + "exceptions": { + "remote_path_not_allowed": { + "message": "Cannot send email with attachment '{file_name} form directory '{file_path} which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + } } } diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 06110a3e5dc..182b45d9c1b 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -11,6 +11,7 @@ from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -111,7 +112,7 @@ EMAIL_DATA = [ ), ( "Test msg", - {"html": HTML, "images": ["test.jpg"]}, + {"html": HTML, "images": ["tests/testing_config/notify/test_not_exists.jpg"]}, "Content-Type: multipart/related", ), ( @@ -156,7 +157,6 @@ def test_send_message( ) def test_sending_insecure_files_fails( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, message_data, data, content_type, @@ -165,10 +165,19 @@ def test_sending_insecure_files_fails( """Verify if we cannot send messages with insecure attachments.""" sample_email = "" message.hass = hass - with patch("email.utils.make_msgid", return_value=sample_email): + with patch("email.utils.make_msgid", return_value=sample_email), pytest.raises( + ServiceValidationError + ) as exc: result, _ = message.send_message(message_data, data=data) assert content_type in result - assert "test.jpg' is not secure to load data from, ignoring attachment" + assert exc.value.translation_key == "remote_path_not_allowed" + assert exc.value.translation_domain == DOMAIN + assert ( + str(exc.value.translation_placeholders["file_path"]) + == "tests/testing_config/notify" + ) + assert exc.value.translation_placeholders["url"] + assert exc.value.translation_placeholders["file_name"] == "test.jpg" def test_send_text_message(hass: HomeAssistant, message) -> None: From df8f462370de29dda8e8ac2d03af01781f77baf1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Dec 2023 13:10:51 +0100 Subject: [PATCH 952/982] Update frontend to 20231204.0 (#104990) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b6668383b54..e254eda0689 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231130.0"] + "requirements": ["home-assistant-frontend==20231204.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7dad258068d..4f998e7a663 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2967035208a..3b916366478 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5c4936f6f3..d2194cd6e79 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 8fd2e6451a0e44e0c56c6fd55571680042d66271 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:48:56 +0100 Subject: [PATCH 953/982] W-Z: add host field description (#104996) --- homeassistant/components/weatherflow/strings.json | 4 +++- homeassistant/components/webostv/strings.json | 4 +++- homeassistant/components/wled/strings.json | 3 +++ homeassistant/components/yamaha_musiccast/strings.json | 3 +++ homeassistant/components/yardian/strings.json | 3 +++ homeassistant/components/yeelight/strings.json | 3 +++ homeassistant/components/youless/strings.json | 3 +++ homeassistant/components/zeversolar/strings.json | 3 +++ 8 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index 8f7a98abe04..d075ee34a05 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -2,10 +2,12 @@ "config": { "step": { "user": { - "title": "WeatherFlow discovery", "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tempest WeatherFlow device." } } }, diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index a5e7b73e59e..1d045d48ba5 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -3,11 +3,13 @@ "flow_title": "LG webOS Smart TV", "step": { "user": { - "title": "Connect to webOS TV", "description": "Turn on TV, fill the following fields click submit", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your webOS TV." } }, "pairing": { diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 61b9cc450fe..eff6dfab572 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -6,6 +6,9 @@ "description": "Set up your WLED to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your WLED device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index c4f28fc750b..d0ee6c030a6 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -6,6 +6,9 @@ "description": "Set up MusicCast to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yamaha MusicCast receiver." } }, "confirm": { diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index f841f3d3ed1..fcaef65ee3e 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yardian Smart Sprinkler Controller. You can find it in the Yardian app." } } }, diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index ab22f42dae3..72baec52c85 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yeelight Wi-Fi bulb." } }, "pick_device": { diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 563e6834ddd..e0eddd7d137 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your YouLess device." } } }, diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json index 0e2e23f244c..b75bbe781ef 100644 --- a/homeassistant/components/zeversolar/strings.json +++ b/homeassistant/components/zeversolar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Zeversolar inverter." } } }, From ca147060d999f4e041712b57cc13e2bd5c7b6480 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 4 Dec 2023 15:00:20 +0100 Subject: [PATCH 954/982] Bump version to 2023.12.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9e1fda9865a..b8ce579ffe8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index c5a1e2705d4..2d5333697bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b1" +version = "2023.12.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 55bafc260d807cc0172c47a1133ea27af6c39bfd Mon Sep 17 00:00:00 2001 From: GeoffAtHome Date: Tue, 5 Dec 2023 13:03:39 +0000 Subject: [PATCH 955/982] Fix geniushub smart plug state at start-up (#102110) * Smart plug did state wrong at start-up * Update docstring to reflect code --- homeassistant/components/geniushub/switch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 79ba418d509..7b9bf8f6112 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -68,9 +68,12 @@ class GeniusSwitch(GeniusZone, SwitchEntity): def is_on(self) -> bool: """Return the current state of the on/off zone. - The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). + The zone is considered 'on' if the mode is either 'override' or 'timer'. """ - return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] + return ( + self._zone.data["mode"] in ["override", "timer"] + and self._zone.data["setpoint"] + ) async def async_turn_off(self, **kwargs: Any) -> None: """Send the zone to Timer mode. From 655b067277018fa1f869c5ff39ea0699041f690f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 13:37:43 -0800 Subject: [PATCH 956/982] Add due date and description to Google Tasks (#104654) * Add tests for config validation function * Add Google Tasks due date and description * Revert test timezone * Update changes after upstream * Update homeassistant/components/google_tasks/todo.py Co-authored-by: Martin Hjelmare * Add google tasks tests for creating --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/google_tasks/todo.py | 39 ++++++--- .../google_tasks/snapshots/test_todo.ambr | 58 ++++++++++--- tests/components/google_tasks/test_todo.py | 85 +++++++++---------- 3 files changed, 114 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index d3c4dfa6936..130c0d2cc01 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -1,7 +1,7 @@ """Google Tasks todo platform.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, datetime, timedelta from typing import Any, cast from homeassistant.components.todo import ( @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .api import AsyncConfigEntryAuth from .const import DOMAIN @@ -35,9 +36,31 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str]: result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + if (due := item.due) is not None: + # due API field is a timestamp string, but with only date resolution + result["due"] = dt_util.start_of_local_day(due).isoformat() + if (description := item.description) is not None: + result["notes"] = description return result +def _convert_api_item(item: dict[str, str]) -> TodoItem: + """Convert tasks API items into a TodoItem.""" + due: date | None = None + if (due_str := item.get("due")) is not None: + due = datetime.fromisoformat(due_str).date() + return TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status", ""), + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.get("notes"), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -68,6 +91,8 @@ class GoogleTaskTodoListEntity( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -88,17 +113,7 @@ class GoogleTaskTodoListEntity( """Get the current set of To-do items.""" if self.coordinator.data is None: return None - return [ - TodoItem( - summary=item["title"], - uid=item["id"], - status=TODO_STATUS_MAP.get( - item.get("status"), # type: ignore[arg-type] - TodoItemStatus.NEEDS_ACTION, - ), - ) - for item in _order_tasks(self.coordinator.data) - ] + return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)] async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 73289b313d9..7d6eb920593 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -1,11 +1,29 @@ # serializer version: 1 -# name: test_create_todo_list_item[api_responses0] +# name: test_create_todo_list_item[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', 'POST', ) # --- -# name: test_create_todo_list_item[api_responses0].1 +# name: test_create_todo_list_item[description].1 + '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' +# --- +# name: test_create_todo_list_item[due] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[due].1 + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_create_todo_list_item[summary] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[summary].1 '{"title": "Soda", "status": "needsAction"}' # --- # name: test_delete_todo_list_item[_handler] @@ -38,6 +56,33 @@ }), ]) # --- +# name: test_partial_update[description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[description].1 + '{"notes": "6-pack"}' +# --- +# name: test_partial_update[due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[due_date].1 + '{"due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_partial_update[rename] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[rename].1 + '{"title": "Soda"}' +# --- # name: test_partial_update_status[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -47,15 +92,6 @@ # name: test_partial_update_status[api_responses0].1 '{"status": "needsAction"}' # --- -# name: test_partial_update_title[api_responses0] - tuple( - 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', - 'PATCH', - ) -# --- -# name: test_partial_update_title[api_responses0].1 - '{"title": "Soda"}' -# --- # name: test_update_todo_list_item[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 0b82815b33a..3329f89c1ca 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -19,13 +19,12 @@ from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator ENTITY_ID = "todo.my_tasks" +ITEM = { + "id": "task-list-id-1", + "title": "My tasks", +} LIST_TASK_LIST_RESPONSE = { - "items": [ - { - "id": "task-list-id-1", - "title": "My tasks", - }, - ] + "items": [ITEM], } EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { @@ -76,6 +75,20 @@ LIST_TASKS_RESPONSE_MULTIPLE = { ], } +# API responses when testing update methods +UPDATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update +] +CREATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh +] + @pytest.fixture def platforms() -> list[str]: @@ -207,12 +220,14 @@ def mock_http_response(response_handler: list | Callable) -> Mock: "title": "Task 1", "status": "needsAction", "position": "0000000000000001", + "due": "2023-11-18T00:00:00+00:00", }, { "id": "task-2", "title": "Task 2", "status": "completed", "position": "0000000000000002", + "notes": "long description", }, ], }, @@ -238,11 +253,13 @@ async def test_get_items( "uid": "task-1", "summary": "Task 1", "status": "needs_action", + "due": "2023-11-18", }, { "uid": "task-2", "summary": "Task 2", "status": "completed", + "description": "long description", }, ] @@ -333,21 +350,20 @@ async def test_task_items_error_response( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, - EMPTY_RESPONSE, # create - LIST_TASKS_RESPONSE, # refresh after delete - ] + (CREATE_API_RESPONSES, {}), + (CREATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (CREATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=["summary", "due", "description"], ) async def test_create_todo_list_item( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Mock, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for creating a To-do Item.""" @@ -361,7 +377,7 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -407,17 +423,7 @@ async def test_create_todo_list_item_error( ) -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_update_todo_list_item( hass: HomeAssistant, setup_credentials: None, @@ -483,21 +489,20 @@ async def test_update_todo_list_item_error( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] + (UPDATE_API_RESPONSES, {"rename": "Soda"}), + (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (UPDATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=("rename", "due_date", "description"), ) -async def test_partial_update_title( +async def test_partial_update( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Any, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for partial update with title only.""" @@ -511,7 +516,7 @@ async def test_partial_update_title( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "some-task-id", "rename": "Soda"}, + {"item": "some-task-id", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -522,17 +527,7 @@ async def test_partial_update_title( assert call.kwargs.get("body") == snapshot -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_partial_update_status( hass: HomeAssistant, setup_credentials: None, From db6b80429856bb066193a808457852380c49df18 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 22:01:57 -0800 Subject: [PATCH 957/982] Add due date and description fields to Todoist To-do entity (#104655) * Add Todoist Due date and description fields * Update entity features with new names * Make items into walrus * Update due_datetime field * Add additional tests for adding new fields to items * Fix call args in todoist test --- homeassistant/components/todoist/todo.py | 41 ++++- tests/components/todoist/conftest.py | 5 +- tests/components/todoist/test_todo.py | 211 +++++++++++++++++++++-- 3 files changed, 236 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index c0d3ec6e2ce..64e83b8cc6e 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -1,7 +1,8 @@ """A todo platform for Todoist.""" import asyncio -from typing import cast +import datetime +from typing import Any, cast from homeassistant.components.todo import ( TodoItem, @@ -13,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import TodoistCoordinator @@ -30,6 +32,24 @@ async def async_setup_entry( ) +def _task_api_data(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["content"] = summary + if due := item.due: + if isinstance(due, datetime.datetime): + item_data["due"] = { + "date": due.date().isoformat(), + "datetime": due.isoformat(), + } + else: + item_data["due"] = {"date": due.isoformat()} + if description := item.description: + item_data["description"] = description + return item_data + + class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): """A Todoist TodoListEntity.""" @@ -37,6 +57,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -66,11 +89,21 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit status = TodoItemStatus.COMPLETED else: status = TodoItemStatus.NEEDS_ACTION + due: datetime.date | datetime.datetime | None = None + if task_due := task.due: + if task_due.datetime: + due = dt_util.as_local( + datetime.datetime.fromisoformat(task_due.datetime) + ) + elif task_due.date: + due = datetime.date.fromisoformat(task_due.date) items.append( TodoItem( summary=task.content, uid=task.id, status=status, + due=due, + description=task.description or None, # Don't use empty string ) ) self._attr_todo_items = items @@ -81,7 +114,7 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if item.status != TodoItemStatus.NEEDS_ACTION: raise ValueError("Only active tasks may be created.") await self.coordinator.api.add_task( - content=item.summary or "", + **_task_api_data(item), project_id=self._project_id, ) await self.coordinator.async_refresh() @@ -89,8 +122,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) - if item.summary: - await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if update_data := _task_api_data(item): + await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: if item.status == TodoItemStatus.COMPLETED: await self.coordinator.api.close_task(task_id=uid) diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 28f22e1061a..4e4d41b6914 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -45,6 +45,7 @@ def make_api_task( is_completed: bool = False, due: Due | None = None, project_id: str | None = None, + description: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -55,8 +56,8 @@ def make_api_task( content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", - description="A task", - due=due or Due(is_recurring=False, date=TODAY, string="today"), + description=description, + due=due, id=id or "1", labels=["Label1"], order=1, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index fb6f707be47..aa00e2c2ff4 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -1,7 +1,9 @@ """Unit tests for the Todoist todo platform.""" +from typing import Any from unittest.mock import AsyncMock import pytest +from todoist_api_python.models import Due, Task from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform @@ -19,6 +21,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.mark.parametrize( ("tasks", "expected_state"), [ @@ -57,11 +65,91 @@ async def test_todo_item_state( assert state.state == expected_state -@pytest.mark.parametrize(("tasks"), [[]]) +@pytest.mark.parametrize( + ("tasks", "item_data", "tasks_after_update", "add_kwargs", "expected_item"), + [ + ( + [], + {}, + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"content": "Soda"}, + {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, + ), + ( + [], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["summary", "due_date", "due_datetime", "description"], +) async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + item_data: dict[str, Any], + tasks_after_update: list[Task], + add_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for adding a To-do Item.""" @@ -71,28 +159,35 @@ async def test_add_todo_list_item( api.add_task = AsyncMock() # Fake API response when state is refreshed after create - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=False) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.name"}, blocking=True, ) args = api.add_task.call_args assert args - assert args.kwargs.get("content") == "Soda" - assert args.kwargs.get("project_id") == PROJECT_ID + assert args.kwargs == {"project_id": PROJECT_ID, "content": "Soda", **add_kwargs} # Verify state is refreshed state = hass.states.get("todo.name") assert state assert state.state == "1" + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} + @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] @@ -158,12 +253,91 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] + ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), + [ + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"rename": "Milk"}, + [make_api_task(id="task-id-1", content="Milk", is_completed=False)], + {"task_id": "task-id-1", "content": "Milk"}, + {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "task_id": "task-id-1", + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"task_id": "task-id-1", "description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["rename", "due_date", "due_datetime", "description"], ) -async def test_update_todo_item_summary( +async def test_update_todo_items( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + update_data: dict[str, Any], + tasks_after_update: list[Task], + update_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for updating a To-do Item that changes the summary.""" @@ -174,22 +348,29 @@ async def test_update_todo_item_summary( api.update_task = AsyncMock() # Fake API response when state is refreshed after close - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=True) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "task-id-1", "rename": "Milk"}, + {"item": "task-id-1", **update_data}, target={"entity_id": "todo.name"}, blocking=True, ) assert api.update_task.called args = api.update_task.call_args assert args - assert args.kwargs.get("task_id") == "task-id-1" - assert args.kwargs.get("content") == "Milk" + assert args.kwargs == update_kwargs + + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} @pytest.mark.parametrize( From 5a49e1dd5cbf1ab20eb9c2b30f5ca3eb13166662 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 4 Dec 2023 14:13:15 -0600 Subject: [PATCH 958/982] Add Wyoming satellite (#104759) * First draft of Wyoming satellite * Set up homeassistant in tests * Move satellite * Add devices with binary sensor and select * Add more events * Add satellite enabled switch * Fix mistake * Only set up necessary platforms for satellites * Lots of fixes * Add tests * Use config entry id as satellite id * Initial satellite test * Add satellite pipeline test * More tests * More satellite tests * Only support single device per config entry * Address comments * Make a copy of platforms --- homeassistant/components/wyoming/__init__.py | 77 ++- .../components/wyoming/binary_sensor.py | 55 +++ .../components/wyoming/config_flow.py | 91 +++- homeassistant/components/wyoming/data.py | 39 +- homeassistant/components/wyoming/devices.py | 85 ++++ homeassistant/components/wyoming/entity.py | 24 + .../components/wyoming/manifest.json | 4 +- homeassistant/components/wyoming/models.py | 13 + homeassistant/components/wyoming/satellite.py | 380 +++++++++++++++ homeassistant/components/wyoming/select.py | 47 ++ homeassistant/components/wyoming/strings.json | 30 +- homeassistant/components/wyoming/stt.py | 5 +- homeassistant/components/wyoming/switch.py | 65 +++ homeassistant/components/wyoming/tts.py | 5 +- homeassistant/components/wyoming/wake_word.py | 5 +- homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wyoming/__init__.py | 28 +- tests/components/wyoming/conftest.py | 47 +- .../wyoming/snapshots/test_config_flow.ambr | 42 ++ .../components/wyoming/test_binary_sensor.py | 34 ++ tests/components/wyoming/test_config_flow.py | 81 ++- tests/components/wyoming/test_data.py | 43 +- tests/components/wyoming/test_devices.py | 78 +++ tests/components/wyoming/test_satellite.py | 460 ++++++++++++++++++ tests/components/wyoming/test_select.py | 83 ++++ tests/components/wyoming/test_switch.py | 32 ++ 28 files changed, 1802 insertions(+), 60 deletions(-) create mode 100644 homeassistant/components/wyoming/binary_sensor.py create mode 100644 homeassistant/components/wyoming/devices.py create mode 100644 homeassistant/components/wyoming/entity.py create mode 100644 homeassistant/components/wyoming/models.py create mode 100644 homeassistant/components/wyoming/satellite.py create mode 100644 homeassistant/components/wyoming/select.py create mode 100644 homeassistant/components/wyoming/switch.py create mode 100644 tests/components/wyoming/test_binary_sensor.py create mode 100644 tests/components/wyoming/test_devices.py create mode 100644 tests/components/wyoming/test_satellite.py create mode 100644 tests/components/wyoming/test_select.py create mode 100644 tests/components/wyoming/test_switch.py diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 33064d21097..2cc9b7050a0 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,17 +4,26 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService +from .devices import SatelliteDevice +from .models import DomainDataItem +from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) +SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH] + __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup_entry", + "async_unload_entry", ] @@ -25,24 +34,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if service is None: raise ConfigEntryNotReady("Unable to connect") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service + item = DomainDataItem(service=service) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = item - await hass.config_entries.async_forward_entry_setups( - entry, - service.platforms, - ) + await hass.config_entries.async_forward_entry_setups(entry, service.platforms) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + if (satellite_info := service.info.satellite) is not None: + # Create satellite device, etc. + item.satellite = _make_satellite(hass, entry, service) + + # Set up satellite sensors, switches, etc. + await hass.config_entries.async_forward_entry_setups(entry, SATELLITE_PLATFORMS) + + # Start satellite communication + entry.async_create_background_task( + hass, + item.satellite.run(), + f"Satellite {satellite_info.name}", + ) + + entry.async_on_unload(item.satellite.stop) return True +def _make_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService +) -> WyomingSatellite: + """Create Wyoming satellite/device from config entry and Wyoming service.""" + satellite_info = service.info.satellite + assert satellite_info is not None + + dev_reg = dr.async_get(hass) + + # Use config entry id since only one satellite per entry is supported + satellite_id = config_entry.entry_id + + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, satellite_id)}, + name=satellite_info.name, + suggested_area=satellite_info.area, + ) + + satellite_device = SatelliteDevice( + satellite_id=satellite_id, + device_id=device.id, + ) + + return WyomingSatellite(hass, service, satellite_device) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Wyoming.""" - service: WyomingService = hass.data[DOMAIN][entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, - service.platforms, - ) + platforms = list(item.service.platforms) + if item.satellite is not None: + platforms += SATELLITE_PLATFORMS + + unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: del hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py new file mode 100644 index 00000000000..4f2c0bb170a --- /dev/null +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -0,0 +1,55 @@ +"""Binary sensor for Wyoming.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteAssistInProgress(item.satellite.device)]) + + +class WyomingSatelliteAssistInProgress(WyomingSatelliteEntity, BinarySensorEntity): + """Entity to represent Assist is in progress for satellite.""" + + entity_description = BinarySensorEntityDescription( + key="assist_in_progress", + translation_key="assist_in_progress", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self._device.set_is_active_listener(self._is_active_changed) + + @callback + def _is_active_changed(self) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index f6b8ed73890..b766fc80c89 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -1,19 +1,22 @@ """Config flow for Wyoming integration.""" from __future__ import annotations +import logging from typing import Any from urllib.parse import urlparse import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components import hassio, zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .data import WyomingService +_LOGGER = logging.getLogger() + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -27,7 +30,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _hassio_discovery: HassioServiceInfo + _hassio_discovery: hassio.HassioServiceInfo + _service: WyomingService | None = None + _name: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -50,27 +55,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, ) - # ASR = automated speech recognition (speech-to-text) - asr_installed = [asr for asr in service.info.asr if asr.installed] + if name := service.get_name(): + return self.async_create_entry(title=name, data=user_input) - # TTS = text-to-speech - tts_installed = [tts for tts in service.info.tts if tts.installed] + return self.async_abort(reason="no_services") - # wake-word-detection - wake_installed = [wake for wake in service.info.wake if wake.installed] - - if asr_installed: - name = asr_installed[0].name - elif tts_installed: - name = tts_installed[0].name - elif wake_installed: - name = wake_installed[0].name - else: - return self.async_abort(reason="no_services") - - return self.async_create_entry(title=name, data=user_input) - - async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + async def async_step_hassio( + self, discovery_info: hassio.HassioServiceInfo + ) -> FlowResult: """Handle Supervisor add-on discovery.""" await self.async_set_unique_id(discovery_info.uuid) self._abort_if_unique_id_configured() @@ -93,11 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: uri = urlparse(self._hassio_discovery.config["uri"]) if service := await WyomingService.create(uri.hostname, uri.port): - if ( - not any(asr for asr in service.info.asr if asr.installed) - and not any(tts for tts in service.info.tts if tts.installed) - and not any(wake for wake in service.info.wake if wake.installed) - ): + if not service.has_services(): return self.async_abort(reason="no_services") return self.async_create_entry( @@ -112,3 +100,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"addon": self._hassio_discovery.name}, errors=errors, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Discovery info: %s", discovery_info) + if discovery_info.port is None: + return self.async_abort(reason="no_port") + + service = await WyomingService.create(discovery_info.host, discovery_info.port) + if (service is None) or (not (name := service.get_name())): + # No supported services + return self.async_abort(reason="no_services") + + self._name = name + + # Use zeroconf name + service name as unique id. + # The satellite will use its own MAC as the zeroconf name by default. + unique_id = f"{discovery_info.name}_{self._name}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self.context[CONF_NAME] = self._name + self.context["title_placeholders"] = {"name": self._name} + + self._service = service + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + assert self._service is not None + assert self._name is not None + + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self._name}, + errors={}, + ) + + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self._service.host, + CONF_PORT: self._service.port, + }, + ) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 64b92eb8471..ea58181a707 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from wyoming.client import AsyncTcpClient -from wyoming.info import Describe, Info +from wyoming.info import Describe, Info, Satellite from homeassistant.const import Platform @@ -32,6 +32,43 @@ class WyomingService: platforms.append(Platform.WAKE_WORD) self.platforms = platforms + def has_services(self) -> bool: + """Return True if services are installed that Home Assistant can use.""" + return ( + any(asr for asr in self.info.asr if asr.installed) + or any(tts for tts in self.info.tts if tts.installed) + or any(wake for wake in self.info.wake if wake.installed) + or ((self.info.satellite is not None) and self.info.satellite.installed) + ) + + def get_name(self) -> str | None: + """Return name of first installed usable service.""" + # ASR = automated speech recognition (speech-to-text) + asr_installed = [asr for asr in self.info.asr if asr.installed] + if asr_installed: + return asr_installed[0].name + + # TTS = text-to-speech + tts_installed = [tts for tts in self.info.tts if tts.installed] + if tts_installed: + return tts_installed[0].name + + # wake-word-detection + wake_installed = [wake for wake in self.info.wake if wake.installed] + if wake_installed: + return wake_installed[0].name + + # satellite + satellite_installed: Satellite | None = None + + if (self.info.satellite is not None) and self.info.satellite.installed: + satellite_installed = self.info.satellite + + if satellite_installed: + return satellite_installed.name + + return None + @classmethod async def create(cls, host: str, port: int) -> WyomingService | None: """Create a Wyoming service.""" diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py new file mode 100644 index 00000000000..90dad889707 --- /dev/null +++ b/homeassistant/components/wyoming/devices.py @@ -0,0 +1,85 @@ +"""Class to manage satellite devices.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN + + +@dataclass +class SatelliteDevice: + """Class to store device.""" + + satellite_id: str + device_id: str + is_active: bool = False + is_enabled: bool = True + pipeline_name: str | None = None + + _is_active_listener: Callable[[], None] | None = None + _is_enabled_listener: Callable[[], None] | None = None + _pipeline_listener: Callable[[], None] | None = None + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + if active != self.is_active: + self.is_active = active + if self._is_active_listener is not None: + self._is_active_listener() + + @callback + def set_is_enabled(self, enabled: bool) -> None: + """Set enabled state.""" + if enabled != self.is_enabled: + self.is_enabled = enabled + if self._is_enabled_listener is not None: + self._is_enabled_listener() + + @callback + def set_pipeline_name(self, pipeline_name: str) -> None: + """Inform listeners that pipeline selection has changed.""" + if pipeline_name != self.pipeline_name: + self.pipeline_name = pipeline_name + if self._pipeline_listener is not None: + self._pipeline_listener() + + @callback + def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: + """Listen for updates to is_active.""" + self._is_active_listener = is_active_listener + + @callback + def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None: + """Listen for updates to is_enabled.""" + self._is_enabled_listener = is_enabled_listener + + @callback + def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: + """Listen for updates to pipeline.""" + self._pipeline_listener = pipeline_listener + + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for assist in progress binary sensor.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress" + ) + + def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite enabled switch.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.satellite_id}-satellite_enabled" + ) + + def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for pipeline select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-pipeline" + ) diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py new file mode 100644 index 00000000000..5ed890bc60e --- /dev/null +++ b/homeassistant/components/wyoming/entity.py @@ -0,0 +1,24 @@ +"""Wyoming entities.""" + +from __future__ import annotations + +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN +from .satellite import SatelliteDevice + + +class WyomingSatelliteEntity(entity.Entity): + """Wyoming satellite entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: SatelliteDevice) -> None: + """Initialize entity.""" + self._device = device + self._attr_unique_id = f"{device.satellite_id}-{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.satellite_id)}, + ) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index ddb5407e1ce..540aaa9aeac 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -3,7 +3,9 @@ "name": "Wyoming Protocol", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, + "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.2.0"] + "requirements": ["wyoming==1.3.0"], + "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/models.py b/homeassistant/components/wyoming/models.py new file mode 100644 index 00000000000..dce45d509eb --- /dev/null +++ b/homeassistant/components/wyoming/models.py @@ -0,0 +1,13 @@ +"""Models for wyoming.""" +from dataclasses import dataclass + +from .data import WyomingService +from .satellite import WyomingSatellite + + +@dataclass +class DomainDataItem: + """Domain data item.""" + + service: WyomingService + satellite: WyomingSatellite | None = None diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py new file mode 100644 index 00000000000..caf65db115e --- /dev/null +++ b/homeassistant/components/wyoming/satellite.py @@ -0,0 +1,380 @@ +"""Support for Wyoming satellite services.""" +import asyncio +from collections.abc import AsyncGenerator +import io +import logging +from typing import Final +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize, SynthesizeVoice +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, stt, tts +from homeassistant.components.assist_pipeline import select as pipeline_select +from homeassistant.core import Context, HomeAssistant + +from .const import DOMAIN +from .data import WyomingService +from .devices import SatelliteDevice + +_LOGGER = logging.getLogger() + +_SAMPLES_PER_CHUNK: Final = 1024 +_RECONNECT_SECONDS: Final = 10 +_RESTART_SECONDS: Final = 3 + +# Wyoming stage -> Assist stage +_STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { + PipelineStage.WAKE: assist_pipeline.PipelineStage.WAKE_WORD, + PipelineStage.ASR: assist_pipeline.PipelineStage.STT, + PipelineStage.HANDLE: assist_pipeline.PipelineStage.INTENT, + PipelineStage.TTS: assist_pipeline.PipelineStage.TTS, +} + + +class WyomingSatellite: + """Remove voice satellite running the Wyoming protocol.""" + + def __init__( + self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice + ) -> None: + """Initialize satellite.""" + self.hass = hass + self.service = service + self.device = device + self.is_enabled = True + self.is_running = True + + self._client: AsyncTcpClient | None = None + self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) + self._is_pipeline_running = False + self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self._pipeline_id: str | None = None + self._enabled_changed_event = asyncio.Event() + + self.device.set_is_enabled_listener(self._enabled_changed) + self.device.set_pipeline_listener(self._pipeline_changed) + + async def run(self) -> None: + """Run and maintain a connection to satellite.""" + _LOGGER.debug("Running satellite task") + + try: + while self.is_running: + try: + # Check if satellite has been disabled + if not self.device.is_enabled: + await self.on_disabled() + if not self.is_running: + # Satellite was stopped while waiting to be enabled + break + + # Connect and run pipeline loop + await self._run_once() + except asyncio.CancelledError: + raise + except Exception: # pylint: disable=broad-exception-caught + await self.on_restart() + finally: + # Ensure sensor is off + self.device.set_is_active(False) + + await self.on_stopped() + + def stop(self) -> None: + """Signal satellite task to stop running.""" + self.is_running = False + + # Unblock waiting for enabled + self._enabled_changed_event.set() + + async def on_restart(self) -> None: + """Block until pipeline loop will be restarted.""" + _LOGGER.warning( + "Unexpected error running satellite. Restarting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RESTART_SECONDS) + + async def on_reconnect(self) -> None: + """Block until a reconnection attempt should be made.""" + _LOGGER.debug( + "Failed to connect to satellite. Reconnecting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RECONNECT_SECONDS) + + async def on_disabled(self) -> None: + """Block until device may be enabled again.""" + await self._enabled_changed_event.wait() + + async def on_stopped(self) -> None: + """Run when run() has fully stopped.""" + _LOGGER.debug("Satellite task stopped") + + # ------------------------------------------------------------------------- + + def _enabled_changed(self) -> None: + """Run when device enabled status changes.""" + + if not self.device.is_enabled: + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + self._enabled_changed_event.set() + + def _pipeline_changed(self) -> None: + """Run when device pipeline changes.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + async def _run_once(self) -> None: + """Run pipelines until an error occurs.""" + self.device.set_is_active(False) + + while self.is_running and self.is_enabled: + try: + await self._connect() + break + except ConnectionError: + await self.on_reconnect() + + assert self._client is not None + _LOGGER.debug("Connected to satellite") + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled during connection + return + + # Tell satellite that we're ready + await self._client.write_event(RunSatellite().event()) + + # Wait until we get RunPipeline event + run_pipeline: RunPipeline | None = None + while self.is_running and self.is_enabled: + run_event = await self._client.read_event() + if run_event is None: + raise ConnectionResetError("Satellite disconnected") + + if RunPipeline.is_type(run_event.type): + run_pipeline = RunPipeline.from_event(run_event) + break + + _LOGGER.debug("Unexpected event from satellite: %s", run_event) + + assert run_pipeline is not None + _LOGGER.debug("Received run information: %s", run_pipeline) + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled while waiting for + # RunPipeline event. + return + + start_stage = _STAGES.get(run_pipeline.start_stage) + end_stage = _STAGES.get(run_pipeline.end_stage) + + if start_stage is None: + raise ValueError(f"Invalid start stage: {start_stage}") + + if end_stage is None: + raise ValueError(f"Invalid end stage: {end_stage}") + + # Each loop is a pipeline run + while self.is_running and self.is_enabled: + # Use select to get pipeline each time in case it's changed + pipeline_id = pipeline_select.get_chosen_pipeline( + self.hass, + DOMAIN, + self.device.satellite_id, + ) + pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) + assert pipeline is not None + + # We will push audio in through a queue + self._audio_queue = asyncio.Queue() + stt_stream = self._stt_stream() + + # Start pipeline running + _LOGGER.debug( + "Starting pipeline %s from %s to %s", + pipeline.name, + start_stage, + end_stage, + ) + self._is_pipeline_running = True + _pipeline_task = asyncio.create_task( + assist_pipeline.async_pipeline_from_audio_stream( + self.hass, + context=Context(), + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language=pipeline.language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=stt_stream, + start_stage=start_stage, + end_stage=end_stage, + tts_audio_output="wav", + pipeline_id=pipeline_id, + ) + ) + + # Run until pipeline is complete or cancelled with an empty audio chunk + while self._is_pipeline_running: + client_event = await self._client.read_event() + if client_event is None: + raise ConnectionResetError("Satellite disconnected") + + if AudioChunk.is_type(client_event.type): + # Microphone audio + chunk = AudioChunk.from_event(client_event) + chunk = self._chunk_converter.convert(chunk) + self._audio_queue.put_nowait(chunk.audio) + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + _LOGGER.debug("Pipeline finished") + + def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: + """Translate pipeline events into Wyoming events.""" + assert self._client is not None + + if event.type == assist_pipeline.PipelineEventType.RUN_END: + # Pipeline run is complete + self._is_pipeline_running = False + self.device.set_is_active(False) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: + self.hass.add_job(self._client.write_event(Detect().event())) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: + # Wake word detection + self.device.set_is_active(True) + + # Inform client of wake word detection + if event.data and (wake_word_output := event.data.get("wake_word_output")): + detection = Detection( + name=wake_word_output["wake_word_id"], + timestamp=wake_word_output.get("timestamp"), + ) + self.hass.add_job(self._client.write_event(detection.event())) + elif event.type == assist_pipeline.PipelineEventType.STT_START: + # Speech-to-text + self.device.set_is_active(True) + + if event.data: + self.hass.add_job( + self._client.write_event( + Transcribe(language=event.data["metadata"]["language"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: + # User started speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStarted(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: + # User stopped speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStopped(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_END: + # Speech-to-text transcript + if event.data: + # Inform client of transript + stt_text = event.data["stt_output"]["text"] + self.hass.add_job( + self._client.write_event(Transcript(text=stt_text).event()) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_START: + # Text-to-speech text + if event.data: + # Inform client of text + self.hass.add_job( + self._client.write_event( + Synthesize( + text=event.data["tts_input"], + voice=SynthesizeVoice( + name=event.data.get("voice"), + language=event.data.get("language"), + ), + ).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_END: + # TTS stream + if event.data and (tts_output := event.data["tts_output"]): + media_id = tts_output["media_id"] + self.hass.add_job(self._stream_tts(media_id)) + + async def _connect(self) -> None: + """Connect to satellite over TCP.""" + _LOGGER.debug( + "Connecting to satellite at %s:%s", self.service.host, self.service.port + ) + self._client = AsyncTcpClient(self.service.host, self.service.port) + await self._client.connect() + + async def _stream_tts(self, media_id: str) -> None: + """Stream TTS WAV audio to satellite in chunks.""" + assert self._client is not None + + extension, data = await tts.async_get_media_source_audio(self.hass, media_id) + if extension != "wav": + raise ValueError(f"Cannot stream audio format to satellite: {extension}") + + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() + ) + + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + + async def _stt_stream(self) -> AsyncGenerator[bytes, None]: + """Yield audio chunks from a queue.""" + is_first_chunk = True + while chunk := await self._audio_queue.get(): + if is_first_chunk: + is_first_chunk = False + _LOGGER.debug("Receiving audio from satellite") + + yield chunk diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py new file mode 100644 index 00000000000..2929ae79fa0 --- /dev/null +++ b/homeassistant/components/wyoming/select.py @@ -0,0 +1,47 @@ +"""Select entities for VoIP integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import SatelliteDevice +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatellitePipelineSelect(hass, item.satellite.device)]) + + +class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): + """Pipeline selector for Wyoming satellites.""" + + def __init__(self, hass: HomeAssistant, device: SatelliteDevice) -> None: + """Initialize a pipeline selector.""" + self.device = device + + WyomingSatelliteEntity.__init__(self, device) + AssistPipelineSelect.__init__(self, hass, device.satellite_id) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + await super().async_select_option(option) + self.device.set_pipeline_name(option) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 20d73d8dc13..19b6a513d4b 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -9,6 +9,10 @@ }, "hassio_confirm": { "description": "Do you want to configure Home Assistant to connect to the Wyoming service provided by the add-on: {addon}?" + }, + "zeroconf_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Wyoming service {name}?", + "title": "Discovered Wyoming service" } }, "error": { @@ -16,7 +20,31 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_services": "No services found at endpoint" + "no_services": "No services found at endpoint", + "no_port": "No port for endpoint" + } + }, + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, + "noise_suppression": { + "name": "Noise suppression" + } + }, + "switch": { + "satellite_enabled": { + "name": "Satellite enabled" + } } } } diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index e64a2f14667..8a21ef051fc 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -24,10 +25,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingSttProvider(config_entry, service), + WyomingSttProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py new file mode 100644 index 00000000000..2bc43122588 --- /dev/null +++ b/homeassistant/components/wyoming/switch.py @@ -0,0 +1,65 @@ +"""Wyoming switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)]) + + +class WyomingSatelliteEnabledSwitch( + WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity +): + """Entity to represent if satellite is enabled.""" + + entity_description = SwitchEntityDescription( + key="satellite_enabled", + translation_key="satellite_enabled", + entity_category=EntityCategory.CONFIG, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + + # Default to on + self._attr_is_on = (state is None) or (state.state == STATE_ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + self._device.set_is_enabled(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + self._device.set_is_enabled(False) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index cde771cd330..f024f925514 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -16,6 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -26,10 +27,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingTtsProvider(config_entry, service), + WyomingTtsProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index fce8bbf6327..da05e8c9fe1 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import WyomingService, load_wyoming_info from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -25,10 +26,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingWakeWordProvider(hass, config_entry, service), + WyomingWakeWordProvider(hass, config_entry, item.service), ] ) diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e8d117d1f33..55570078d80 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -715,6 +715,11 @@ ZEROCONF = { "domain": "wled", }, ], + "_wyoming._tcp.local.": [ + { + "domain": "wyoming", + }, + ], "_xbmc-jsonrpc-h._tcp.local.": [ { "domain": "kodi", diff --git a/requirements_all.txt b/requirements_all.txt index 3b916366478..de4086b5fb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2750,7 +2750,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2194cd6e79..a25d5b3c566 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2054,7 +2054,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index e04ff4eda03..899eda7ec1a 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,11 +1,13 @@ """Tests for the Wyoming integration.""" import asyncio +from wyoming.event import Event from wyoming.info import ( AsrModel, AsrProgram, Attribution, Info, + Satellite, TtsProgram, TtsVoice, TtsVoiceSpeaker, @@ -72,24 +74,36 @@ WAKE_WORD_INFO = Info( ) ] ) +SATELLITE_INFO = Info( + satellite=Satellite( + name="Test Satellite", + description="Test Satellite", + installed=True, + attribution=TEST_ATTR, + area="Office", + ) +) EMPTY_INFO = Info() class MockAsyncTcpClient: """Mock AsyncTcpClient.""" - def __init__(self, responses) -> None: + def __init__(self, responses: list[Event]) -> None: """Initialize.""" - self.host = None - self.port = None - self.written = [] + self.host: str | None = None + self.port: int | None = None + self.written: list[Event] = [] self.responses = responses - async def write_event(self, event): + async def connect(self) -> None: + """Connect.""" + + async def write_event(self, event: Event): """Send.""" self.written.append(event) - async def read_event(self): + async def read_event(self) -> Event | None: """Receive.""" await asyncio.sleep(0) # force context switch @@ -105,7 +119,7 @@ class MockAsyncTcpClient: async def __aexit__(self, exc_type, exc, tb): """Exit.""" - def __call__(self, host, port): + def __call__(self, host: str, port: int): """Call.""" self.host = host self.port = port diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 2c8081908f7..a30c1048eb6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -5,14 +5,23 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components import stt +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from . import STT_INFO, TTS_INFO, WAKE_WORD_INFO +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def init_components(hass: HomeAssistant): + """Set up required components.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" @@ -110,3 +119,39 @@ def metadata(hass: HomeAssistant) -> stt.SpeechMetadata: sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ) + + +@pytest.fixture +def satellite_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_satellite(hass: HomeAssistant, satellite_config_entry: ConfigEntry): + """Initialize Wyoming satellite.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_setup(satellite_config_entry.entry_id) + + +@pytest.fixture +async def satellite_device( + hass: HomeAssistant, init_satellite, satellite_config_entry: ConfigEntry +) -> SatelliteDevice: + """Get a satellite device fixture.""" + return hass.data[DOMAIN][satellite_config_entry.entry_id].satellite.device diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index d4220a39724..99f411027f5 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -121,3 +121,45 @@ 'version': 1, }) # --- +# name: test_zeroconf_discovery + FlowResultSnapshot({ + 'context': dict({ + 'name': 'Test Satellite', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Test Satellite', + }), + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Test Satellite', + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + 'version': 1, + }), + 'title': 'Test Satellite', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py new file mode 100644 index 00000000000..27294186a90 --- /dev/null +++ b/tests/components/wyoming/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Test Wyoming binary sensor devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_assist_in_progress( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test assist in progress.""" + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active + + satellite_device.set_is_active(True) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_active + + satellite_device.set_is_active(False) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 896d3748ebd..f711b56b3bc 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Wyoming config flow.""" +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch import pytest @@ -8,10 +9,11 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.wyoming.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import EMPTY_INFO, STT_INFO, TTS_INFO +from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO from tests.common import MockConfigEntry @@ -25,6 +27,16 @@ ADDON_DISCOVERY = HassioServiceInfo( uuid="1234", ) +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=IPv4Address("127.0.0.1"), + ip_addresses=[IPv4Address("127.0.0.1")], + port=12345, + hostname="localhost", + type="_wyoming._tcp.local.", + name="test_zeroconf_name._wyoming._tcp.local.", + properties={}, +) + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -214,3 +226,70 @@ async def test_hassio_addon_no_supported_services(hass: HomeAssistant) -> None: assert result2.get("type") == FlowResultType.ABORT assert result2.get("reason") == "no_services" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config flow initiated by Supervisor.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("description_placeholders") == { + "name": SATELLITE_INFO.satellite.name + } + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +async def test_zeroconf_discovery_no_port( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when the zeroconf service does not have a port.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch.object(ZEROCONF_DISCOVERY, "port", None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_port" + + +async def test_zeroconf_discovery_no_services( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when there are no supported services on the client.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=Info(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_services" diff --git a/tests/components/wyoming/test_data.py b/tests/components/wyoming/test_data.py index 0cb878c39c1..b7de9dbfdc1 100644 --- a/tests/components/wyoming/test_data.py +++ b/tests/components/wyoming/test_data.py @@ -3,13 +3,15 @@ from __future__ import annotations from unittest.mock import patch -from homeassistant.components.wyoming.data import load_wyoming_info +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.wyoming.data import WyomingService, load_wyoming_info from homeassistant.core import HomeAssistant -from . import STT_INFO, MockAsyncTcpClient +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO, MockAsyncTcpClient -async def test_load_info(hass: HomeAssistant, snapshot) -> None: +async def test_load_info(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test loading info.""" with patch( "homeassistant.components.wyoming.data.AsyncTcpClient", @@ -38,3 +40,38 @@ async def test_load_info_oserror(hass: HomeAssistant) -> None: ) assert info is None + + +async def test_service_name(hass: HomeAssistant) -> None: + """Test loading service info.""" + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([STT_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == STT_INFO.asr[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([TTS_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == TTS_INFO.tts[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([WAKE_WORD_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == WAKE_WORD_INFO.wake[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([SATELLITE_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == SATELLITE_INFO.satellite.name diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py new file mode 100644 index 00000000000..549f76f20f1 --- /dev/null +++ b/tests/components/wyoming/test_devices.py @@ -0,0 +1,78 @@ +"""Test Wyoming devices.""" +from __future__ import annotations + +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_device_registry_info( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + satellite_config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry.""" + + # Satellite uses config entry id since only one satellite per entry is + # supported. + device = device_registry.async_get_device( + identifiers={(DOMAIN, satellite_config_entry.entry_id)} + ) + assert device is not None + assert device.name == "Test Satellite" + assert device.suggested_area == "Office" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assist_in_progress_state = hass.states.get(assist_in_progress_id) + assert assist_in_progress_state is not None + assert assist_in_progress_state.state == STATE_OFF + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + satellite_enabled_state = hass.states.get(satellite_enabled_id) + assert satellite_enabled_state is not None + assert satellite_enabled_state.state == STATE_ON + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + pipeline_state = hass.states.get(pipeline_entity_id) + assert pipeline_state is not None + assert pipeline_state.state == OPTION_PREFERRED + + +async def test_remove_device_registry_entry( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing a device registry entry.""" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assert hass.states.get(assist_in_progress_id) is not None + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + assert hass.states.get(satellite_enabled_id) is not None + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + assert hass.states.get(pipeline_entity_id) is not None + + # Remove + device_registry.async_remove_device(satellite_device.device_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Everything should be gone + assert hass.states.get(assist_in_progress_id) is None + assert hass.states.get(satellite_enabled_id) is None + assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py new file mode 100644 index 00000000000..06ae337a19c --- /dev/null +++ b/tests/components/wyoming/test_satellite.py @@ -0,0 +1,460 @@ +"""Test Wyoming satellite.""" +from __future__ import annotations + +import asyncio +import io +from unittest.mock import patch +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.event import Event +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, wyoming +from homeassistant.components.wyoming.data import WyomingService +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import SATELLITE_INFO, MockAsyncTcpClient + +from tests.common import MockConfigEntry + + +async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up config entry for Wyoming satellite. + + This is separated from the satellite_config_entry method in conftest.py so + we can patch functions before the satellite task is run during setup. + """ + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def get_test_wav() -> bytes: + """Get bytes for test WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + # Single frame + wav_file.writeframes(b"123") + + return wav_io.getvalue() + + +class SatelliteAsyncTcpClient(MockAsyncTcpClient): + """Satellite AsyncTcpClient.""" + + def __init__(self, responses: list[Event]) -> None: + """Initialize client.""" + super().__init__(responses) + + self.connect_event = asyncio.Event() + self.run_satellite_event = asyncio.Event() + self.detect_event = asyncio.Event() + + self.detection_event = asyncio.Event() + self.detection: Detection | None = None + + self.transcribe_event = asyncio.Event() + self.transcribe: Transcribe | None = None + + self.voice_started_event = asyncio.Event() + self.voice_started: VoiceStarted | None = None + + self.voice_stopped_event = asyncio.Event() + self.voice_stopped: VoiceStopped | None = None + + self.transcript_event = asyncio.Event() + self.transcript: Transcript | None = None + + self.synthesize_event = asyncio.Event() + self.synthesize: Synthesize | None = None + + self.tts_audio_start_event = asyncio.Event() + self.tts_audio_chunk_event = asyncio.Event() + self.tts_audio_stop_event = asyncio.Event() + self.tts_audio_chunk: AudioChunk | None = None + + self._mic_audio_chunk = AudioChunk( + rate=16000, width=2, channels=1, audio=b"chunk" + ).event() + + async def connect(self) -> None: + """Connect.""" + self.connect_event.set() + + async def write_event(self, event: Event): + """Send.""" + if RunSatellite.is_type(event.type): + self.run_satellite_event.set() + elif Detect.is_type(event.type): + self.detect_event.set() + elif Detection.is_type(event.type): + self.detection = Detection.from_event(event) + self.detection_event.set() + elif Transcribe.is_type(event.type): + self.transcribe = Transcribe.from_event(event) + self.transcribe_event.set() + elif VoiceStarted.is_type(event.type): + self.voice_started = VoiceStarted.from_event(event) + self.voice_started_event.set() + elif VoiceStopped.is_type(event.type): + self.voice_stopped = VoiceStopped.from_event(event) + self.voice_stopped_event.set() + elif Transcript.is_type(event.type): + self.transcript = Transcript.from_event(event) + self.transcript_event.set() + elif Synthesize.is_type(event.type): + self.synthesize = Synthesize.from_event(event) + self.synthesize_event.set() + elif AudioStart.is_type(event.type): + self.tts_audio_start_event.set() + elif AudioChunk.is_type(event.type): + self.tts_audio_chunk = AudioChunk.from_event(event) + self.tts_audio_chunk_event.set() + elif AudioStop.is_type(event.type): + self.tts_audio_stop_event.set() + + async def read_event(self) -> Event | None: + """Receive.""" + event = await super().read_event() + + # Keep sending audio chunks instead of None + return event or self._mic_audio_chunk + + +async def test_satellite_pipeline(hass: HomeAssistant) -> None: + """Test running a pipeline with a satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", + return_value=("wav", get_test_wav()), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + + # Start detecting wake word + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_START + ) + ) + async with asyncio.timeout(1): + await mock_client.detect_event.wait() + + assert not device.is_active + assert device.is_enabled + + # Wake word is detected + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_END, + {"wake_word_output": {"wake_word_id": "test_wake_word"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.detection_event.wait() + + assert mock_client.detection is not None + assert mock_client.detection.name == "test_wake_word" + + # "Assist in progress" sensor should be active now + assert device.is_active + + # Speech-to-text started + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_START, + {"metadata": {"language": "en"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcribe_event.wait() + + assert mock_client.transcribe is not None + assert mock_client.transcribe.language == "en" + + # User started speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_started_event.wait() + + assert mock_client.voice_started is not None + assert mock_client.voice_started.timestamp == 1234 + + # User stopped speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_stopped_event.wait() + + assert mock_client.voice_stopped is not None + assert mock_client.voice_stopped.timestamp == 5678 + + # Speech-to-text transcription + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_END, + {"stt_output": {"text": "test transcript"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcript_event.wait() + + assert mock_client.transcript is not None + assert mock_client.transcript.text == "test transcript" + + # Text-to-speech text + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + assert mock_client.synthesize is not None + assert mock_client.synthesize.text == "test text to speak" + assert mock_client.synthesize.voice is not None + assert mock_client.synthesize.voice.name == "test voice" + + # Text-to-speech media + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"media_id": "test media id"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Verify audio chunk from test WAV + assert mock_client.tts_audio_chunk is not None + assert mock_client.tts_audio_chunk.rate == 22050 + assert mock_client.tts_audio_chunk.width == 2 + assert mock_client.tts_audio_chunk.channels == 1 + assert mock_client.tts_audio_chunk.audio == b"123" + + # Pipeline finished + event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + assert not device.is_active + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_satellite_disabled(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been disabled.""" + on_disabled_event = asyncio.Event() + + original_make_satellite = wyoming._make_satellite + + def make_disabled_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService + ): + satellite = original_make_satellite(hass, config_entry, service) + satellite.device.is_enabled = False + + return satellite + + async def on_disabled(self): + on_disabled_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming._make_satellite", make_disabled_satellite + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled", + on_disabled, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_disabled_event.wait() + + +async def test_satellite_restart(hass: HomeAssistant) -> None: + """Test pipeline loop restart after unexpected error.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._run_once", + side_effect=RuntimeError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + +async def test_satellite_reconnect(hass: HomeAssistant) -> None: + """Test satellite reconnect call after connection refused.""" + on_reconnect_event = asyncio.Event() + + async def on_reconnect(self): + self.stop() + on_reconnect_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient.connect", + side_effect=ConnectionRefusedError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect", + on_reconnect, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_reconnect_event.wait() + + +async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting before pipeline run.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient([]), # no RunPipeline event + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + # Pipeline should never have run + mock_run_pipeline.assert_not_called() + + +async def test_satellite_disconnect_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + on_restart_event = asyncio.Event() + on_stopped_event = asyncio.Event() + + async def on_restart(self): + # Pretend sensor got stuck on + self.device.is_active = True + self.stop() + on_restart_event.set() + + async def on_stopped(self): + on_stopped_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient(events), + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await on_restart_event.wait() + await on_stopped_event.wait() + + # Pipeline should have run once + mock_run_pipeline.assert_called_once() + + # Sensor should have been turned off + assert not device.is_active diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py new file mode 100644 index 00000000000..cab699336fb --- /dev/null +++ b/tests/components/wyoming/test_select.py @@ -0,0 +1,83 @@ +"""Test Wyoming select.""" +from unittest.mock import Mock, patch + +from homeassistant.components import assist_pipeline +from homeassistant.components.assist_pipeline.pipeline import PipelineData +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_pipeline_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test pipeline select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + pipeline_data: PipelineData = hass.data[assist_pipeline.DOMAIN] + + # Create second pipeline + await pipeline_data.pipeline_store.async_create_item( + { + "name": "Test 1", + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + + # Preferred pipeline is the default + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # Change to second pipeline + with patch.object(satellite_device, "set_pipeline_name") as mock_pipeline_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": "Test 1"}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + + # async_pipeline_changed should have been called + mock_pipeline_changed.assert_called_once_with("Test 1") + + # Change back and check update listener + pipeline_listener = Mock() + satellite_device.set_pipeline_listener(pipeline_listener) + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": OPTION_PREFERRED}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # listener should have been called + pipeline_listener.assert_called_once() diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py new file mode 100644 index 00000000000..0b05724d761 --- /dev/null +++ b/tests/components/wyoming/test_switch.py @@ -0,0 +1,32 @@ +"""Test Wyoming switch devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_satellite_enabled( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test satellite enabled.""" + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_enabled + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": satellite_enabled_id}, + blocking=True, + ) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_enabled From 99401c60c7883a1ed8461d657091339643582803 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 4 Dec 2023 17:21:41 +0100 Subject: [PATCH 959/982] Add Matter custom cluster sensors (Eve Energy Plug energy measurements) (#104830) * Support for sensors from custom clusters in Matter * lint * no need to write state twice * Add test for eve energy plug * Update homeassistant/components/matter/entity.py Co-authored-by: Martin Hjelmare * adjust comment * debounce extra poll timer * use async_call_later helper * Update homeassistant/components/matter/entity.py Co-authored-by: Martin Hjelmare * wip extend test * Update test_sensor.py * fix state class for sensors * trigger (fake) event callback on all subscribers * Update eve-energy-plug.json * add test for additionally polled value * adjust delay to 3 seconds * Adjust subscribe_events to always use kwargs * Update tests/components/matter/common.py Co-authored-by: Martin Hjelmare * Update test_sensor.py * remove redundant code --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/adapter.py | 9 +- homeassistant/components/matter/discovery.py | 5 +- homeassistant/components/matter/entity.py | 42 +- homeassistant/components/matter/models.py | 6 + homeassistant/components/matter/sensor.py | 74 +- tests/components/matter/common.py | 8 +- .../fixtures/nodes/eve-energy-plug.json | 649 ++++++++++++++++++ tests/components/matter/test_adapter.py | 7 +- tests/components/matter/test_sensor.py | 82 ++- 9 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/eve-energy-plug.json diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 2831ebe9a38..5690996841d 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -97,22 +97,23 @@ class MatterAdapter: self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_added_callback, EventType.ENDPOINT_ADDED + callback=endpoint_added_callback, event_filter=EventType.ENDPOINT_ADDED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_removed_callback, EventType.ENDPOINT_REMOVED + callback=endpoint_removed_callback, + event_filter=EventType.ENDPOINT_REMOVED, ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_removed_callback, EventType.NODE_REMOVED + callback=node_removed_callback, event_filter=EventType.NODE_REMOVED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_added_callback, EventType.NODE_ADDED + callback=node_added_callback, event_filter=EventType.NODE_ADDED ) ) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c971bf8465e..e1d004a15c8 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -115,8 +115,9 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, + should_poll=schema.should_poll, ) - # prevent re-discovery of the same attributes + # prevent re-discovery of the primary attribute if not allowed if not schema.allow_multi: - discovered_attributes.update(attributes_to_watch) + discovered_attributes.update(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7e7b7a688df..de6e6ff83c2 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -5,6 +5,7 @@ from abc import abstractmethod from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass +from datetime import datetime import logging from typing import TYPE_CHECKING, Any, cast @@ -12,9 +13,10 @@ from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.event import async_call_later from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -27,6 +29,13 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +# For some manually polled values (e.g. custom clusters) we perform +# an additional poll as soon as a secondary value changes. +# For example update the energy consumption meter when a relay is toggled +# of an energy metering powerplug. The below constant defined the delay after +# which we poll the primary value (debounced). +EXTRA_POLL_DELAY = 3.0 + @dataclass class MatterEntityDescription(EntityDescription): @@ -39,7 +48,6 @@ class MatterEntityDescription(EntityDescription): class MatterEntity(Entity): """Entity class for Matter devices.""" - _attr_should_poll = False _attr_has_entity_name = True def __init__( @@ -71,6 +79,8 @@ class MatterEntity(Entity): identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) self._attr_available = self._endpoint.node.available + self._attr_should_poll = entity_info.should_poll + self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -110,15 +120,35 @@ class MatterEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() for unsub in self._unsubscribes: with suppress(ValueError): # suppress ValueError to prevent race conditions unsub() + async def async_update(self) -> None: + """Call when the entity needs to be updated.""" + # manually poll/refresh the primary value + await self.matter_client.refresh_attribute( + self._endpoint.node.node_id, + self.get_matter_attribute_path(self._entity_info.primary_attribute), + ) + self._update_from_device() + @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: - """Call on update.""" + """Call on update from the device.""" self._attr_available = self._endpoint.node.available + if self._attr_should_poll: + # secondary attribute updated of a polled primary value + # enforce poll of the primary value a few seconds later + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() + self._extra_poll_timer_unsub = async_call_later( + self.hass, EXTRA_POLL_DELAY, self._do_extra_poll + ) + return self._update_from_device() self.async_write_ha_state() @@ -145,3 +175,9 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) + + @callback + def _do_extra_poll(self, called_at: datetime) -> None: + """Perform (extra) poll of primary value.""" + # scheduling the regulat update is enough to perform a poll/refresh + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 34447751797..5f47f73b139 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -50,6 +50,9 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type + # [optional] bool to specify if this primary value should be polled + should_poll: bool + @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -106,3 +109,6 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] bool to specify if this primary value should be polled + should_poll: bool = False diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 5021ed7fa0d..6262eb253aa 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models.clusters import EveEnergyCluster from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,6 +19,10 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfVolumeFlowRate, @@ -48,7 +53,6 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" - _attr_state_class = SensorStateClass.MEASUREMENT entity_description: MatterSensorEntityDescription @callback @@ -72,6 +76,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), @@ -83,6 +88,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), @@ -94,6 +100,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, translation_key="flow", measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), @@ -105,6 +112,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=( @@ -118,6 +126,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), @@ -131,8 +140,71 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # value has double precision measurement_to_ha=lambda x: int(x / 2), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWatt", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Watt,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Voltage,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattAccumulated", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.WattAccumulated,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Current,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), ] diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index a0935154054..d5093367db5 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -71,6 +71,10 @@ async def trigger_subscription_callback( data: Any = None, ) -> None: """Trigger a subscription callback.""" - callback = client.subscribe_events.call_args.kwargs["callback"] - callback(event, data) + # trigger callback on all subscribers + for sub in client.subscribe_events.call_args_list: + callback = sub.kwargs["callback"] + event_filter = sub.kwargs.get("event_filter") + if event_filter in (None, event): + callback(event, data) await hass.async_block_till_done() diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug.json b/tests/components/matter/fixtures/nodes/eve-energy-plug.json new file mode 100644 index 00000000000..03ff4ce7dba --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-energy-plug.json @@ -0,0 +1,649 @@ +{ + "node_id": 83, + "date_commissioned": "2023-11-30T14:39:37.020026", + "last_interview": "2023-11-30T14:39:37.020029", + "interview_version": 5, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Energy Plug", + "0/40/4": 80, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 6650, + "0/40/10": "3.2.1", + "0/40/15": "RV44L1A00081", + "0/40/18": "26E8F90561D17C42", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [ + { + "1": 2312386028615903905, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "cfUKbvsdfsBjT+0=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "cfUKbvBjdsffwT+0=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "ymtKI/b4u+4=", + "5": [], + "6": [ + "/oAAAAA13414AAADIa0oj9vi77g==", + "/XH1Cm71434wAAB8TZpoASmxuw==", + "/RtUBAb134134mAAAPypryIKqshA==" + ], + "7": 4 + } + ], + "0/51/1": 95, + "0/51/2": 268574, + "0/51/3": 4406, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome23", + "0/53/3": 14707, + "0/53/4": 8211480967175688173, + "0/53/5": "QP1x9Qfwefu8AAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 13418684826835773064, + "1": 9, + "2": 3072, + "3": 56455, + "4": 84272, + "5": 1, + "6": -89, + "7": -88, + "8": 16, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3054316089463545304, + "1": 2, + "2": 12288, + "3": 17170, + "4": 58113, + "5": 3, + "6": -45, + "7": -46, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3650476115380598997, + "1": 13, + "2": 15360, + "3": 172475, + "4": 65759, + "5": 3, + "6": -17, + "7": -18, + "8": 12, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 11968039652259981925, + "1": 21, + "2": 21504, + "3": 127929, + "4": 55363, + "5": 3, + "6": -74, + "7": -72, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17156405262946673420, + "1": 22, + "2": 22528, + "3": 22063, + "4": 137698, + "5": 1, + "6": -92, + "7": -92, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17782243871947087975, + "1": 18, + "2": 23552, + "3": 157044, + "4": 122272, + "5": 2, + "6": -81, + "7": -82, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8276316979900166010, + "1": 17, + "2": 31744, + "3": 486113, + "4": 298427, + "5": 2, + "6": -83, + "7": -82, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9121696247933828996, + "1": 48, + "2": 53248, + "3": 651530, + "4": 161559, + "5": 3, + "6": -70, + "7": -71, + "8": 15, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 13418684826835773064, + "1": 3072, + "2": 3, + "3": 15, + "4": 1, + "5": 1, + "6": 1, + "7": 9, + "8": true, + "9": true + }, + { + "0": 0, + "1": 7168, + "2": 7, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 76, + "8": true, + "9": false + }, + { + "0": 0, + "1": 10240, + "2": 10, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 243, + "8": true, + "9": false + }, + { + "0": 3054316089463545304, + "1": 12288, + "2": 12, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 2, + "8": true, + "9": true + }, + { + "0": 3650476115380598997, + "1": 15360, + "2": 15, + "3": 12, + "4": 1, + "5": 3, + "6": 3, + "7": 14, + "8": true, + "9": true + }, + { + "0": 11968039652259981925, + "1": 21504, + "2": 21, + "3": 15, + "4": 1, + "5": 3, + "6": 2, + "7": 22, + "8": true, + "9": true + }, + { + "0": 17156405262946673420, + "1": 22528, + "2": 22, + "3": 52, + "4": 1, + "5": 1, + "6": 0, + "7": 23, + "8": true, + "9": true + }, + { + "0": 17782243871947087975, + "1": 23552, + "2": 23, + "3": 15, + "4": 1, + "5": 2, + "6": 2, + "7": 19, + "8": true, + "9": true + }, + { + "0": 0, + "1": 29696, + "2": 29, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 8276316979900166010, + "1": 31744, + "2": 31, + "3": 52, + "4": 1, + "5": 2, + "6": 2, + "7": 18, + "8": true, + "9": true + }, + { + "0": 0, + "1": 39936, + "2": 39, + "3": 52, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 9121696247933828996, + "1": 53248, + "2": 52, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 48, + "8": true, + "9": true + }, + { + "0": 14585833336497290222, + "1": 54272, + "2": 53, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + } + ], + "0/53/9": 1828774034, + "0/53/10": 68, + "0/53/11": 237, + "0/53/12": 170, + "0/53/13": 23, + "0/53/14": 2, + "0/53/15": 1, + "0/53/16": 2, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 2, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 293884, + "0/53/23": 278934, + "0/53/24": 14950, + "0/53/25": 278894, + "0/53/26": 278468, + "0/53/27": 14990, + "0/53/28": 293844, + "0/53/29": 0, + "0/53/30": 40, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 65244, + "0/53/34": 426, + "0/53/35": 0, + "0/53/36": 87, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 6687540, + "0/53/40": 142626, + "0/53/41": 106835, + "0/53/42": 246171, + "0/53/43": 0, + "0/53/44": 541, + "0/53/45": 40, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 6360718, + "0/53/49": 2141, + "0/53/50": 35259, + "0/53/51": 4374, + "0/53/52": 0, + "0/53/53": 568, + "0/53/54": 18599, + "0/53/55": 19143, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRUxgkBwEkCAEwCUEEg58CF25hrI1R598dXwRapPCYUjahad5XkJMrA0tZb8HXO67XlyD4L+1ljtb6IAHhxjOGew2jNVSQDH1aqRGsODcKNQEoARgkAgE2AwQCBAEYMAQUkpBmmh0G57MnnxYDgxZuAZBezjYwBRTphWiJ/NqGe3Cx3Nj8H02NgGioSRgwC0CCOOCnKlhpegJmaH8vSIO38MQcJq+qV85UPPqaYc8dakaAnASvYeurP41Jw4KrCqyLMNRhUwqeyKoql6iQFKNAGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEYztrLK2UY1ORHUEFLO7PDfVjw/MnMDNX5kjdHHDU7npeITnSyg/kxxUM+pD7ccxfDuHQKHbBq9+qbJi8oGik8DcKNQEpARgkAmAwBBTphWiJ/NqGe3Cx3Nj8H02NgGioSTAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQOOcZAL8XEktvE5sjrUmFNhkP2g3Ef+4BHtogItdZYyA9E/WbzW25E0UxZInwjjIzH3YimDUZVoEWGML8NV2kCEY", + "254": 5 + } + ], + "0/62/1": [ + { + "1": "BIbR4Iu8CNIdxKRkSjTb1LKY3nzCbFVwDrjkRe4WDorCiMZHJmypZW24wBgAHxNo8D00QWw29llu8FH1eOtmHIo=", + "2": 4937, + "3": 1, + "4": 3878431683, + "5": "Thuis", + "254": 1 + }, + { + "1": "BLlk4ui4wSQ+xz89jB5nBRQUVYdY9H2dBUawGXVUxa2bsKh2k8CHijv1tkz1dThPXA9UK8jOAZ+7Mi+y7BPuAcg=", + "2": 4996, + "3": 2, + "4": 3763070728, + "5": "", + "254": 2 + }, + { + "1": "BAg5aeR7RuFKZhukCxMGglCd00dKlhxGq8BbjeyZClKz5kN2Ytzav0xWsiWEEb3s9uvMIYFoQYULnSJvOMTcD14=", + "2": 65521, + "3": 1, + "4": 83, + "5": "", + "254": 5 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AycUxofpv3kE1HwkFQEYJgS2Ty8rJgU2gxAtNwYnFMaH6b95BNR8JBUBGCQHASQIATAJQQSG0eCLvAjSHcSkZEo029SymN58wmxVcA645EXuFg6KwojGRyZsqWVtuMAYAB8TaPA9NEFsNvZZbvBR9XjrZhyKNwo1ASkBGCQCYDAEFNnFRJ+9qQIJtsM+LRdMdmCY3bQ4MAUU2cVEn72pAgm2wz4tF0x2YJjdtDgYMAtAFDv6Ouh7ugAGLiCjBQaEXCIAe0AkaaN8dBPskCZXOODjuZ1DCr4/f5IYg0rN2zFDUDTvG3GCxoI1+A7BvSjiNRg=", + "FTABAQAkAgE3AycUjuqR8vTQCmEkFQIYJgTFTy8rJgVFgxAtNwYnFI7qkfL00AphJBUCGCQHASQIATAJQQS5ZOLouMEkPsc/PYweZwUUFFWHWPR9nQVGsBl1VMWtm7CodpPAh4o79bZM9XU4T1wPVCvIzgGfuzIvsuwT7gHINwo1ASkBGCQCYDAEFKEEplpzAvCzsc5ga6CFmqmsv5onMAUUoQSmWnMC8LOxzmBroIWaqay/micYMAtAYkkA8OZFIGpxBEYYT+3A7Okba4WOq4NtwctIIZvCM48VU8pxQNjVvHMcJWPOP1Wh2Bw1VH7/Sg9lt9DL4DAwjBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEECDlp5HtG4UpmG6QLEwaCUJ3TR0qWHEarwFuN7JkKUrPmQ3Zi3Nq/TFayJYQRvez268whgWhBhQudIm84xNwPXjcKNQEpARgkAmAwBBTJ3+WZAQkWgZboUpiyZL3FV8R8UzAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQO9QSAdvJkM6b/wIc07MCw1ma46lTyGYG8nvpn0ICI73nuD3QeaWwGIQTkVGEpzF+TuDK7gtTz7YUrR+PSnvMk8Y" + ], + "0/62/5": 5, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 29, 319486977], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFQCwIAAAMC+xkEDFJWNDRMMUEwMDA4MZwBAP8EAQIA1PkBAWABZNAEAAAAAEUFBQAAAABGCQUAAAAOAABCBkkGBQwIEIABRBEFFAAFAzwAAAAAAAAAAAAAAEcRBSoh/CGWImgjeAAAADwAAABIBgUAAAAAAEoGBQAAAAAA", + "1/319486977/319422466": "BEZiAQAAAAAAAAAABgsCDAINAgcCDgEBAn4PABAAWgAAs8c+AQEA", + "1/319486977/319422467": "EgtaAAB74T4BDwAANwkAAAAA", + "1/319486977/319422471": 0, + "1/319486977/319422472": 238.8000030517578, + "1/319486977/319422473": 0.0, + "1/319486977/319422474": 0.0, + "1/319486977/319422475": 0.2200000286102295, + "1/319486977/319422476": 0, + "1/319486977/319422478": 0, + "1/319486977/319422481": false, + "1/319486977/319422482": 54272, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422471, 319422472, 319422473, 319422474, + 319422475, 319422476, 319422478, 319422481, 319422482, 65533 + ] + }, + "attribute_subscriptions": [], + "last_subscription_attempt": 0 +} diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 8ed309f61df..35e6673114e 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -145,9 +145,12 @@ async def test_node_added_subscription( ) -> None: """Test subscription to new devices work.""" assert matter_client.subscribe_events.call_count == 4 - assert matter_client.subscribe_events.call_args[0][1] == EventType.NODE_ADDED + assert ( + matter_client.subscribe_events.call_args.kwargs["event_filter"] + == EventType.NODE_ADDED + ) - node_added_callback = matter_client.subscribe_events.call_args[0][0] + node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] node_data = load_and_parse_node_fixture("onoff-light") node = MatterNode( dataclass_from_dict( diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 0d8f892f992..5b343b8c4e5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -1,5 +1,6 @@ """Test Matter sensors.""" -from unittest.mock import MagicMock +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest @@ -14,6 +15,8 @@ from .common import ( trigger_subscription_callback, ) +from tests.common import async_fire_time_changed + @pytest.fixture(name="flow_sensor_node") async def flow_sensor_node_fixture( @@ -63,6 +66,16 @@ async def temperature_sensor_node_fixture( ) +@pytest.fixture(name="eve_energy_plug_node") +async def eve_energy_plug_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Energy Plug node.""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -208,3 +221,70 @@ async def test_battery_sensor( assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_eve_energy_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + eve_energy_plug_node: MatterNode, +) -> None: + """Test Energy sensors created from Eve Energy custom cluster.""" + # power sensor + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "W" + assert state.attributes["device_class"] == "power" + assert state.attributes["friendly_name"] == "Eve Energy Plug Power" + + # voltage sensor + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "238.800003051758" + assert state.attributes["unit_of_measurement"] == "V" + assert state.attributes["device_class"] == "voltage" + assert state.attributes["friendly_name"] == "Eve Energy Plug Voltage" + + # energy sensor + entity_id = "sensor.eve_energy_plug_energy" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.220000028610229" + assert state.attributes["unit_of_measurement"] == "kWh" + assert state.attributes["device_class"] == "energy" + assert state.attributes["friendly_name"] == "Eve Energy Plug Energy" + assert state.attributes["state_class"] == "total_increasing" + + # current sensor + entity_id = "sensor.eve_energy_plug_current" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "A" + assert state.attributes["device_class"] == "current" + assert state.attributes["friendly_name"] == "Eve Energy Plug Current" + + # test if the sensor gets polled on interval + eve_energy_plug_node.update_attribute("1/319486977/319422472", 237.0) + async_fire_time_changed(hass, datetime.now(UTC) + timedelta(seconds=31)) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "237.0" + + # test extra poll triggered when secondary value (switch state) changes + set_node_attribute(eve_energy_plug_node, 1, 6, 0, True) + eve_energy_plug_node.update_attribute("1/319486977/319422474", 5.0) + with patch("homeassistant.components.matter.entity.EXTRA_POLL_DELAY", 0.0): + await trigger_subscription_callback(hass, matter_client) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "5.0" From 48cce1a854cfd28bf37a3b79011cd9604ef81571 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Mon, 4 Dec 2023 11:37:09 -0800 Subject: [PATCH 960/982] Exclude Todoist sub-tasks for the todo platform (#104914) --- homeassistant/components/todoist/todo.py | 3 +++ tests/components/todoist/conftest.py | 3 ++- tests/components/todoist/test_todo.py | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 64e83b8cc6e..6231a6878ae 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -85,6 +85,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit for task in self.coordinator.data: if task.project_id != self._project_id: continue + if task.parent_id is not None: + # Filter out sub-tasks until they are supported by the UI. + continue if task.is_completed: status = TodoItemStatus.COMPLETED else: diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 4e4d41b6914..42251b0ea18 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -46,6 +46,7 @@ def make_api_task( due: Due | None = None, project_id: str | None = None, description: str | None = None, + parent_id: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -61,7 +62,7 @@ def make_api_task( id=id or "1", labels=["Label1"], order=1, - parent_id=None, + parent_id=parent_id, priority=1, project_id=project_id or PROJECT_ID, section_id=None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index aa00e2c2ff4..1e94b52149c 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -51,6 +51,14 @@ def set_time_zone(hass: HomeAssistant) -> None: ], "0", ), + ( + [ + make_api_task( + id="12345", content="sub-task", is_completed=False, parent_id="1" + ) + ], + "0", + ), ], ) async def test_todo_item_state( From 56e325a2b107369304bc830110574c9361745531 Mon Sep 17 00:00:00 2001 From: Marco <24938492+Marco98@users.noreply.github.com> Date: Tue, 5 Dec 2023 09:21:03 +0100 Subject: [PATCH 961/982] Fix Mikrotik rename from wifiwave2 to wifi for upcoming RouterOS 7.13 (#104966) Co-authored-by: Marco98 --- homeassistant/components/mikrotik/const.py | 4 ++++ homeassistant/components/mikrotik/hub.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 4354b9b06bd..8407dd14a6e 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -25,9 +25,11 @@ CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" WIFIWAVE2: Final = "wifiwave2" +WIFI: Final = "wifi" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" IS_WIFIWAVE2: Final = "is_wifiwave2" +IS_WIFI: Final = "is_wifi" MIKROTIK_SERVICES: Final = { @@ -38,9 +40,11 @@ MIKROTIK_SERVICES: Final = { INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", WIFIWAVE2: "/interface/wifiwave2/registration-table/print", + WIFI: "/interface/wifi/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", IS_WIFIWAVE2: "/interface/wifiwave2/print", + IS_WIFI: "/interface/wifi/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 9e0a610c770..af7dfb2ab2c 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,10 +31,12 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFI, IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFI, WIFIWAVE2, WIRELESS, ) @@ -60,6 +62,7 @@ class MikrotikData: self.support_capsman: bool = False self.support_wireless: bool = False self.support_wifiwave2: bool = False + self.support_wifi: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -101,6 +104,7 @@ class MikrotikData: self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) + self.support_wifi = bool(self.command(MIKROTIK_SERVICES[IS_WIFI])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -128,6 +132,9 @@ class MikrotikData: elif self.support_wifiwave2: _LOGGER.debug("Hub supports wifiwave2 Interface") device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) + elif self.support_wifi: + _LOGGER.debug("Hub supports wifi Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFI) if not device_list or self.force_dhcp: device_list = self.all_devices From fd4a05fc7a6e519b5e29469d17a2283b7183f52c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 10:47:49 +0100 Subject: [PATCH 962/982] Minor improvements of deprecation helper (#104980) --- homeassistant/helpers/deprecation.py | 44 +++++++++++++++++++--------- tests/util/yaml/test_init.py | 4 +-- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index c499dd0b6cd..5a0682fdda2 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -99,7 +99,11 @@ def get_deprecated( def deprecated_class( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark class as deprecated and provide a replacement class to be used instead.""" + """Mark class as deprecated and provide a replacement class to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate class as deprecated.""" @@ -107,7 +111,7 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class") + _print_deprecation_warning(cls, replacement, "class", "instantiated") return cls(*args, **kwargs) return deprecated_cls @@ -118,7 +122,11 @@ def deprecated_class( def deprecated_function( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark function as deprecated and provide a replacement to be used instead.""" + """Mark function as deprecated and provide a replacement to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate function as deprecated.""" @@ -126,7 +134,7 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function") + _print_deprecation_warning(func, replacement, "function", "called") return func(*args, **kwargs) return deprecated_func @@ -134,10 +142,23 @@ def deprecated_function( return deprecated_decorator -def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: +def _print_deprecation_warning( + obj: Any, + replacement: str, + description: str, + verb: str, +) -> None: logger = logging.getLogger(obj.__module__) try: integration_frame = get_integration_frame() + except MissingIntegrationFrame: + logger.warning( + "%s is a deprecated %s. Use %s instead", + obj.__name__, + description, + replacement, + ) + else: if integration_frame.custom_integration: hass: HomeAssistant | None = None with suppress(HomeAssistantError): @@ -149,10 +170,11 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) logger.warning( ( - "%s was called from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s. Use %s instead," " please %s" ), obj.__name__, + verb, integration_frame.integration, description, replacement, @@ -160,16 +182,10 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) else: logger.warning( - "%s was called from %s, this is a deprecated %s. Use %s instead", + "%s was %s from %s, this is a deprecated %s. Use %s instead", obj.__name__, + verb, integration_frame.integration, description, replacement, ) - except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, - ) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 3a2d9b3734d..6f6f48813ce 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -636,8 +636,8 @@ async def test_deprecated_loaders( ): loader_class() assert ( - f"{loader_class.__name__} was called from hue, this is a deprecated class. " - f"Use {new_class} instead" + f"{loader_class.__name__} was instantiated from hue, this is a deprecated " + f"class. Use {new_class} instead" ) in caplog.text From c62c002657d51ca13c789f8c4b627c08e9698f39 Mon Sep 17 00:00:00 2001 From: Bartosz Dokurno Date: Mon, 4 Dec 2023 14:58:37 +0100 Subject: [PATCH 963/982] Update Todoist config flow URL (#104992) --- homeassistant/components/todoist/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index b8c79210dfb..94b4ad31826 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -16,7 +16,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SETTINGS_URL = "https://todoist.com/app/settings/integrations" +SETTINGS_URL = "https://app.todoist.com/app/settings/integrations/developer" STEP_USER_DATA_SCHEMA = vol.Schema( { From 65c8aa32494430b9531153b0b6112b8ed858527a Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 08:55:15 +0100 Subject: [PATCH 964/982] Make unifi RX-/TX-sensors diagnostic entities (#105022) --- homeassistant/components/unifi/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3d0ffa1896e..1e4a4520d5c 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -151,6 +151,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:upload", @@ -171,6 +172,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:download", From 2f727d5fe15380fabda39bb8e81e11c92cc7d324 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 09:42:43 +0100 Subject: [PATCH 965/982] Fix stuck clients in UniFi options (#105028) --- homeassistant/components/unifi/config_flow.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index a678517eca9..e1867b2df2e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -8,6 +8,7 @@ Configuration of options through options flow. from __future__ import annotations from collections.abc import Mapping +import operator import socket from types import MappingProxyType from typing import Any @@ -309,6 +310,11 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): client.mac: f"{client.name or client.hostname} ({client.mac})" for client in self.controller.api.clients.values() } + clients |= { + mac: f"Unknown ({mac})" + for mac in self.options.get(CONF_CLIENT_SOURCE, []) + if mac not in clients + } return self.async_show_form( step_id="configure_entity_sources", @@ -317,7 +323,9 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_CLIENT_SOURCE, default=self.options.get(CONF_CLIENT_SOURCE, []), - ): cv.multi_select(clients), + ): cv.multi_select( + dict(sorted(clients.items(), key=operator.itemgetter(1))) + ), } ), last_step=False, From 7cb383146a9566fd0ae1213f0d177f14fa2ce5d7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 08:50:32 +0100 Subject: [PATCH 966/982] Make UniFi WiFi clients numerical (#105032) --- homeassistant/components/unifi/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 1e4a4520d5c..4d5cf49b5c9 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -233,6 +233,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="WLAN clients", entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, allowed_fn=lambda controller, obj_id: True, api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, From 55c686ad0303bfaa6ba30fc53fcb86436d5be161 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 11:36:26 +0100 Subject: [PATCH 967/982] Don't use deprecated_class decorator on deprecated YAML classes (#105063) --- homeassistant/util/yaml/loader.py | 60 ++++++++++++++++++++++++++++--- tests/util/yaml/test_init.py | 15 ++++---- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 275a51cd760..4a14afb53b2 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -1,7 +1,7 @@ """Custom loader.""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import suppress import fnmatch from io import StringIO, TextIOWrapper @@ -23,7 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -137,10 +137,36 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("FastSafeLoader") 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.""" @@ -151,10 +177,36 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("PythonSafeLoader") 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," + ) + LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 6f6f48813ce..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -590,7 +590,7 @@ async def test_loading_actual_file_with_syntax_error( def mock_integration_frame() -> Generator[Mock, None, None]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( - filename="/home/paulus/.homeassistant/custom_components/hue/light.py", + filename="/home/paulus/homeassistant/components/hue/light.py", lineno="23", line="self.light.is_on", ) @@ -614,12 +614,12 @@ def mock_integration_frame() -> Generator[Mock, None, None]: @pytest.mark.parametrize( - ("loader_class", "new_class"), + ("loader_class", "message"), [ - (yaml.loader.SafeLoader, "FastSafeLoader"), + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), ( yaml.loader.SafeLineLoader, - "PythonSafeLoader", + "'SafeLineLoader' instead of 'PythonSafeLoader'", ), ], ) @@ -628,17 +628,14 @@ async def test_deprecated_loaders( mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, loader_class, - new_class: str, + 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"{loader_class.__name__} was instantiated from hue, this is a deprecated " - f"class. Use {new_class} instead" - ) in caplog.text + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text def test_string_annotated(try_both_loaders) -> None: From a076b7d992f166fd426ccdfaa9a282b3fceb417f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Dec 2023 15:57:40 +0100 Subject: [PATCH 968/982] Bump version to 2023.12.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b8ce579ffe8..ecb09a5d621 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 2d5333697bb..26114091715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b2" +version = "2023.12.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4018a285101d92280737bef5f552b7599f0d60c2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 5 Dec 2023 18:52:22 +0100 Subject: [PATCH 969/982] Remove device from known_devices upon import in ping device tracker (#105009) Co-authored-by: Joost Lekkerkerker --- .../components/device_tracker/legacy.py | 13 +++ .../components/ping/device_tracker.py | 100 +++++++++++++----- .../components/device_tracker/test_legacy.py | 44 ++++++++ tests/components/ping/test_device_tracker.py | 41 ++++++- 4 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 tests/components/device_tracker/test_legacy.py diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index f18f7984e1e..c931d256689 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -1033,6 +1033,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None: out.write(dump(device_config)) +def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None: + """Remove device from YAML configuration file.""" + path = hass.config.path(YAML_DEVICES) + devices = load_yaml_config_file(path) + devices.pop(device_id) + dumped = dump(devices) + + with open(path, "r+", encoding="utf8") as out: + out.seek(0) + out.truncate() + out.write(dumped) + + def get_gravatar_for_email(email: str) -> str: """Return an 80px Gravatar for the given email address. diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index ceff1b2e124..417659aad5c 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -11,9 +12,20 @@ from homeassistant.components.device_tracker import ( ScannerEntity, SourceType, ) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + remove_device_from_config, +) +from homeassistant.config import load_yaml_config_file from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_HOSTS, + CONF_NAME, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -42,34 +54,66 @@ async def async_setup_scanner( ) -> bool: """Legacy init: import via config flow.""" - for dev_name, dev_host in config[CONF_HOSTS].items(): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_IMPORTED_BY: "device_tracker", - CONF_NAME: dev_name, - CONF_HOST: dev_host, - CONF_PING_COUNT: config[CONF_PING_COUNT], - }, - ) + async def _run_import(_: Event) -> None: + """Delete devices from known_device.yaml and import them via config flow.""" + _LOGGER.debug( + "Home Assistant successfully started, importing ping device tracker config entries now" ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Ping", - }, - ) + devices: dict[str, dict[str, Any]] = {} + try: + devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" + ) + + for dev_name, dev_host in config[CONF_HOSTS].items(): + if dev_name in devices: + await hass.async_add_executor_job( + remove_device_from_config, hass, dev_name + ) + _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) + + if not hass.states.async_available(f"device_tracker.{dev_name}"): + hass.states.async_remove(f"device_tracker.{dev_name}") + + # run import after everything has been cleaned up + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_IMPORTED_BY: "device_tracker", + CONF_NAME: dev_name, + CONF_HOST: dev_host, + CONF_PING_COUNT: config[CONF_PING_COUNT], + }, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, + ) + + # delay the import until after Home Assistant has started and everything has been initialized, + # as the legacy device tracker entities will be restored after the legacy device tracker platforms + # have been set up, so we can only remove the entities from the state machine then + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) return True diff --git a/tests/components/device_tracker/test_legacy.py b/tests/components/device_tracker/test_legacy.py new file mode 100644 index 00000000000..d7a2f33c23b --- /dev/null +++ b/tests/components/device_tracker/test_legacy.py @@ -0,0 +1,44 @@ +"""Tests for the legacy device tracker component.""" +from unittest.mock import mock_open, patch + +from homeassistant.components.device_tracker import legacy +from homeassistant.core import HomeAssistant +from homeassistant.util.yaml import dump + +from tests.common import patch_yaml_files + + +def test_remove_device_from_config(hass: HomeAssistant): + """Test the removal of a device from a config.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + "test2": { + "hide_if_away": True, + "mac": "00:ab:cd:33:44:55", + "name": "Test2", + "picture": "/local/test2.png", + "track": True, + }, + } + mopen = mock_open() + + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + with patch_yaml_files(files, True), patch( + "homeassistant.components.device_tracker.legacy.open", mopen + ): + legacy.remove_device_from_config(hass, "test") + + mopen().write.assert_called_once_with( + "test2:\n" + " hide_if_away: true\n" + " mac: 00:ab:cd:33:44:55\n" + " name: Test2\n" + " picture: /local/test2.png\n" + " track: true\n" + ) diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index b6cc6b42912..5f5bb2132c1 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,13 +1,17 @@ """Test the binary sensor platform of ping.""" +from unittest.mock import patch import pytest +from homeassistant.components.device_tracker import legacy from homeassistant.components.ping.const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import dump -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, patch_yaml_files @pytest.mark.usefixtures("setup_integration") @@ -56,7 +60,42 @@ async def test_import_issue_creation( ) await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) assert issue + + +async def test_import_delete_known_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +): + """Test if import deletes known devices.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + } + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + + with patch_yaml_files(files, True), patch( + "homeassistant.components.ping.device_tracker.remove_device_from_config" + ) as remove_device_from_config: + await async_setup_component( + hass, + "device_tracker", + {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(remove_device_from_config.mock_calls) == 1 From 30d529aab08e331ac480a188b40ea332566a12e9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Dec 2023 18:52:52 +0100 Subject: [PATCH 970/982] Update frontend to 20231205.0 (#105081) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index e254eda0689..08eb0f0a424 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231204.0"] + "requirements": ["home-assistant-frontend==20231205.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4f998e7a663..1b089a57104 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index de4086b5fb3..0d6a007c2be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a25d5b3c566..f30a4a90b58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From b0367d3d749477c021048f425c9f2683b4f09799 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Dec 2023 19:09:24 +0100 Subject: [PATCH 971/982] Bump version to 2023.12.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ecb09a5d621..719b661e68f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 26114091715..b207c9f1aa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b3" +version = "2023.12.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ae4811b776893bce76669446837750ae0b1df647 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 5 Dec 2023 11:45:00 -0800 Subject: [PATCH 972/982] Update apple_weatherkit to 1.1.1 (#105079) --- homeassistant/components/weatherkit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index d28a6ff3315..a2ddde02ad4 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.0.4"] + "requirements": ["apple_weatherkit==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0d6a007c2be..ebd3377df33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -437,7 +437,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f30a4a90b58..cf3d1a330c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 From f7c9d20472c0ecfaa32e3b827975ca6fa815fa01 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 5 Dec 2023 20:00:53 +0100 Subject: [PATCH 973/982] Fix overkiz measurement sensor returns None if 0 (#105090) --- homeassistant/components/overkiz/sensor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 0bb9043c040..a267b54b398 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -481,7 +481,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): """Return the value of the sensor.""" state = self.device.states.get(self.entity_description.key) - if not state or not state.value: + if ( + state is None + or state.value is None + or self.state_class != SensorStateClass.MEASUREMENT + and not state.value + ): return None # Transform the value with a lambda function From 990fd31e8439c6beec21db73163a8c8859a6bcd4 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 22:16:07 +0100 Subject: [PATCH 974/982] Bump aiounifi to v67 (#105099) * Bump aiounifi to v67 * Fix mypy --- homeassistant/components/unifi/controller.py | 4 ++-- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 6bd8b9db426..035cf66a983 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,7 +5,7 @@ import asyncio from datetime import datetime, timedelta import ssl from types import MappingProxyType -from typing import Any +from typing import Any, Literal from aiohttp import CookieJar import aiounifi @@ -458,7 +458,7 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context: ssl.SSLContext | bool = False + ssl_context: ssl.SSLContext | Literal[False] = False if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 52ed8ec3101..7d4717d3fff 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==66"], + "requirements": ["aiounifi==67"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index ebd3377df33..a2954cc33f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -374,7 +374,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf3d1a330c8..e65de1c0177 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 681a3fd271eb8aaf85129180a2641c2573cc02a6 Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Tue, 5 Dec 2023 22:25:08 +0100 Subject: [PATCH 975/982] Fix typo in v2c strings.json (#105104) fo -> of --- homeassistant/components/v2c/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index dafdd597e77..bf19fe5188e 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -6,7 +6,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address fo your V2C Trydan EVSE." + "host": "Hostname or IP address of your V2C Trydan EVSE." } } }, From da766bc7c5b9403087d2b96927e07b65f55430f0 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 6 Dec 2023 01:14:34 -0600 Subject: [PATCH 976/982] Bump intents to 2023.12.05 (#105116) --- homeassistant/components/conversation/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/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2a069d5d92b..cb03499d8e4 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.29"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1b089a57104..c471cd765fd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231205.0 -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index a2954cc33f0..206c7216c47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1017,7 +1017,7 @@ holidays==0.36 home-assistant-frontend==20231205.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e65de1c0177..7a51b943717 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,7 +804,7 @@ holidays==0.36 home-assistant-frontend==20231205.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 From 9fcb72238126fbbb42f650e363181bdf696d8b6c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Dec 2023 08:31:21 +0100 Subject: [PATCH 977/982] Bump version to 2023.12.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 719b661e68f..c240983ce6c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index b207c9f1aa3..4a0be2840b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b4" +version = "2023.12.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6b3e9904c8a30d1c47e76812f3b95f529a407a6c Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Wed, 6 Dec 2023 11:01:05 +0100 Subject: [PATCH 978/982] Add missing services and strings entries for reply_to_message_id (#105072) --- .../components/telegram_bot/services.yaml | 45 +++++++++++++++++- .../components/telegram_bot/strings.json | 46 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 94d1eee1b55..1587f754508 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -34,7 +34,6 @@ send_message: min: 1 max: 3600 unit_of_measurement: seconds - keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -50,6 +49,10 @@ send_message: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_photo: fields: @@ -117,6 +120,10 @@ send_photo: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_sticker: fields: @@ -177,6 +184,10 @@ send_sticker: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_animation: fields: @@ -240,6 +251,14 @@ send_animation: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box send_video: fields: @@ -307,6 +326,10 @@ send_video: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_voice: fields: @@ -367,6 +390,10 @@ send_voice: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_document: fields: @@ -434,6 +461,10 @@ send_document: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_location: fields: @@ -480,6 +511,10 @@ send_location: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_poll: fields: @@ -516,6 +551,14 @@ send_poll: min: 1 max: 3600 unit_of_measurement: seconds + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box edit_message: fields: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 4dfe0a28d01..de5de685409 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -42,7 +42,11 @@ }, "message_tag": { "name": "Message tag", - "description": "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}." + "description": "Tag for sent message." + }, + "reply_to_message_id": { + "name": "Reply to message id", + "description": "Mark the message as a reply to a previous message." } } }, @@ -105,6 +109,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -163,6 +171,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -221,6 +233,14 @@ "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -283,6 +303,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -341,6 +365,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -403,6 +431,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -441,6 +473,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -479,6 +515,14 @@ "timeout": { "name": "Timeout", "description": "Timeout for send poll. Will help with timeout errors (poor internet connection, etc)." + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, From e165d6741eb922162c848009ac5188c816cbbb7c Mon Sep 17 00:00:00 2001 From: Xidorn Quan Date: Wed, 6 Dec 2023 23:30:31 +1100 Subject: [PATCH 979/982] Bump thermopro-ble to 0.5.0 (#105126) --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index b48760f773d..a0a07d3cb00 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.4.5"] + "requirements": ["thermopro-ble==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 206c7216c47..5aa8ce31266 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2591,7 +2591,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a51b943717..fa692c71c0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1928,7 +1928,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 0958e8fadf233986281a7142d063847cabb57d41 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Dec 2023 14:39:27 +0100 Subject: [PATCH 980/982] Fix missing target in todo.remove_completed_items service (#105127) --- homeassistant/components/todo/services.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index bc7da7db941..8ecc9e0ec86 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -86,3 +86,8 @@ remove_item: text: remove_completed_items: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.DELETE_TODO_ITEM From d8b056b3407e5ffe25adad1b422816599d196c6d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 6 Dec 2023 14:51:36 +0100 Subject: [PATCH 981/982] Update frontend to 20231206.0 (#105132) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 08eb0f0a424..af2ea6f9149 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231205.0"] + "requirements": ["home-assistant-frontend==20231206.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c471cd765fd..e8e45a9393e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5aa8ce31266..fda92edee3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa692c71c0f..675cfa7c646 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From af23580530e0fd5b9d46805e2b013567fbfc5d7f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Dec 2023 16:31:24 +0100 Subject: [PATCH 982/982] Bump version to 2023.12.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c240983ce6c..8267fd29390 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 4a0be2840b2..b6bb8649b03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b5" +version = "2023.12.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"